Skip to main content

cranpose_render_wgpu/
render.rs

1//! GPU rendering implementation using WGPU
2
3use crate::effect_renderer::{
4    projective_dest_bounds_rect, CompositeBatchItem, CompositeSampleMode, EffectRenderer,
5    EffectScratchTargetProvider, ProjectiveSurfaceComposite, RoundedCompositeMask,
6    ShaderCompositeBatchItem,
7};
8use crate::frame_graph::{
9    FrameCommandRecorder, FrameTextureDescriptor, WgpuFrameGraph, WgpuFrameGraphExecutor,
10};
11use crate::frame_packet::{
12    CancelReason, FramePacket, PacketRoot, PresentOutcome, RenderReturns, RootSurfacePacket,
13};
14use crate::layer_events::{
15    collect_effect_ranges, collect_layer_events, LayerEvent, LayerEventKind,
16};
17use crate::layer_surface_cache::LayerSurfaceCache;
18use crate::lazy_resource::LazyGpuResource;
19#[cfg(test)]
20use crate::normalized_scene::{
21    build_scene_window, collect_layer_contents, collect_layer_contents_with_translation_context,
22    filtered_effect_layer_index, scene_bounds, SceneWindowSource,
23};
24#[cfg(test)]
25use crate::normalized_scene::{estimate_layer_surface_rect, motion_stable_capture_bounds};
26use crate::normalized_scene::{translate_quad, ChildLayerComposite, CollectedLayer};
27use crate::offscreen::OffscreenTarget;
28use crate::rect_to_quad;
29use crate::scene::{
30    BackdropLayer, CompositorScene, DrawOp, DrawOpKind, DrawShape, EffectLayer, ImageDraw,
31    RetainedDraw, SceneBrush, ShadowDraw, SimilarityTransform, SnapAnchor, TextDraw,
32};
33use crate::shaders;
34#[cfg(test)]
35use crate::surface_executor::surface_target_size;
36use crate::surface_executor::{
37    apply_backdrop_layer_to_target as execute_apply_backdrop_layer_to_target,
38    axis_aligned_quad_rect, backdrop_underlay_is_covered_by_local_content,
39    canonicalize_device_coordinate, canonicalized_scaled_quad, canonicalized_scaled_rect,
40    composite_surface_to_view as execute_composite_surface_to_view, device_pixel_bounds_for_rect,
41    offscreen_byte_size, render_effect_layer_to_target as execute_render_effect_layer_to_target,
42    render_layer_surface as execute_render_layer_surface,
43    render_root_direct as execute_render_root_direct, root_direct_scene_events_are_supported,
44    scaled_quad, snap_delta_for_anchor, snap_motion_stable_dest_quad,
45    translation_stable_anchored_device_pixel_bounds, DevicePixelBounds, LayerSurfaceTexture,
46    SurfaceExecutionBackend,
47};
48#[cfg(test)]
49use crate::surface_executor::{clamp_effect_surface_scale, visible_layer_rect};
50#[cfg(test)]
51use crate::surface_plan::root_can_render_directly_cached;
52#[cfg(test)]
53use crate::surface_plan::{
54    composite_sample_mode_for_effect_layer, composite_sample_mode_for_requirements,
55    direct_translation, effect_layer_target_scale, layer_contains_descendant_backdrop,
56    layer_surface_requirements, layer_surface_requirements_cached, layer_surface_scale,
57    layer_surface_target_scale, layer_uses_external_backdrop_input, TranslatedContentAxes,
58};
59use crate::surface_plan::{LayerSurfaceRequest, TranslationRenderContext};
60#[cfg(test)]
61use crate::surface_requirements::SurfaceRequirement;
62use crate::surface_requirements::SurfaceRequirementSet;
63use crate::DebugCpuAllocationStats;
64use bytemuck::{Pod, Zeroable};
65#[cfg(any(not(target_arch = "wasm32"), test))]
66use cranpose_core::collections::map::HashMap;
67use cranpose_core::{hash::default as default_hash, NodeId};
68use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
69use cranpose_render_common::geometry::blur_extent_margin;
70use cranpose_render_common::graph::quad_bounds;
71#[cfg(test)]
72use cranpose_render_common::graph::{
73    CachePolicy, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform,
74    RenderNode,
75};
76use cranpose_render_common::raster_cache::LayerRasterCacheKey;
77#[cfg(test)]
78use cranpose_render_common::raster_cache::ScaleBucket;
79use cranpose_render_common::software_text_raster::{
80    collect_solid_text_atlas_run, measure_text_with_font,
81    rasterize_annotated_text_to_image_with_glyph_cache, rasterize_text_to_image_with_glyph_cache,
82    SoftwareGlyphAtlasGlyph, SoftwareGlyphAtlasKey, SoftwareGlyphAtlasPlacement,
83    SoftwareGlyphAtlasRunGlyph, SoftwareGlyphRasterCache, SoftwareTextFontSet,
84};
85#[cfg(test)]
86use cranpose_ui_graphics::GraphicsLayer;
87use cranpose_ui_graphics::{
88    BlendMode, Brush, Color, ColorFilter, FxHasher, ImageBitmap, ImageSampling, Point, Rect,
89    RenderEffect, RenderHash, RuntimeShader, StrokeCap, StrokeJoin, TileMode,
90};
91use std::borrow::Cow;
92use std::cell::Cell;
93use std::hash::{Hash, Hasher};
94use std::ops::Range;
95use std::rc::Rc;
96#[cfg(not(target_arch = "wasm32"))]
97use std::sync::atomic::{AtomicUsize, Ordering};
98use std::sync::{mpsc, Arc};
99use std::time::Duration;
100use web_time::Instant;
101
102use crate::gpu_stats;
103use crate::gpu_stats::gpu_stats_enabled;
104use crate::pipeline::push_layer_shadow;
105
106/// Must equal the `array<ShapeData, N>` literal in `shape.wgsl`: on wasm the
107/// shader source is used verbatim, so a larger batch cap here would index past
108/// the declared array. 102 x 160-byte ShapeData = 16320 bytes, the most that
109/// fits WebGL's 16 KiB uniform-binding floor.
110#[cfg(target_arch = "wasm32")]
111const MAX_SHAPES_PER_BATCH: usize = 102;
112#[cfg(not(target_arch = "wasm32"))]
113const MAX_SHAPES_PER_BATCH: usize = 768;
114#[cfg(target_arch = "wasm32")]
115const MAX_GRADIENT_STOPS: usize = 256;
116#[cfg(not(target_arch = "wasm32"))]
117const MAX_GRADIENT_STOPS: usize = 1024;
118
119/// Per-pass ceilings when the shape and gradient arrays live in storage
120/// buffers instead of uniforms. These are not hardware limits — storage
121/// bindings are hundreds of megabytes everywhere — they bound worst-case
122/// buffer growth: 65 536 shapes is a 7 MiB shape buffer and a 12 MiB vertex
123/// buffer, far past any real scene, while still forcing a batch split before
124/// a pathological one can ask for gigabytes.
125#[cfg(not(target_arch = "wasm32"))]
126const MAX_SHAPES_PER_STORAGE_BATCH: usize = 1 << 16;
127#[cfg(not(target_arch = "wasm32"))]
128const MAX_GRADIENT_STOPS_PER_STORAGE_BATCH: usize = 1 << 16;
129
130/// How many shapes/stops the storage-mode buffers start out sized for. In
131/// uniform mode the initial capacity must equal the cap (a uniform binding
132/// smaller than the shader's fixed-length array fails validation), but a
133/// runtime-sized storage array binds at any size, so start small and let
134/// `ensure_capacity` double toward the cap as scenes demand.
135#[cfg(not(target_arch = "wasm32"))]
136const INITIAL_STORAGE_BATCH_CAPACITY: usize = 1024;
137
138/// Shape/gradient batch capacities derived from the actual device limits.
139///
140/// Where storage buffers are available (any real Vulkan/Metal/D3D device, and
141/// GL only when it exposes SSBOs to fragment shaders) the arrays are bound as
142/// read-only storage and a whole scene fits one batch. Otherwise they fall
143/// back to uniform arrays: the compile-time `MAX_*` constants assume
144/// desktop-class 64 KiB uniform bindings, while Android downlevel and
145/// GLES-class devices may only offer the 16 KiB spec minimum; sizing the
146/// buffers (and the matching WGSL array lengths) past
147/// `max_uniform_buffer_binding_size` makes the very first "Shape Bind Group"
148/// fail validation and aborts the app.
149#[derive(Clone, Copy, Debug, Eq, PartialEq)]
150struct ShapeBatchLimits {
151    max_shapes_per_batch: usize,
152    max_gradient_stops: usize,
153    storage: bool,
154}
155
156impl ShapeBatchLimits {
157    fn for_device(device: &wgpu::Device) -> Self {
158        let limits = device.limits();
159        #[cfg(not(target_arch = "wasm32"))]
160        if limits.max_storage_buffers_per_shader_stage >= 2 {
161            return Self::for_storage_binding_size(limits.max_storage_buffer_binding_size);
162        }
163        Self::for_uniform_binding_size(limits.max_uniform_buffer_binding_size)
164    }
165
166    fn for_uniform_binding_size(max_uniform_buffer_binding_size: u64) -> Self {
167        let binding = max_uniform_buffer_binding_size as usize;
168        Self {
169            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
170                .clamp(1, MAX_SHAPES_PER_BATCH),
171            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
172                .clamp(1, MAX_GRADIENT_STOPS),
173            storage: false,
174        }
175    }
176
177    #[cfg(not(target_arch = "wasm32"))]
178    fn for_storage_binding_size(max_storage_buffer_binding_size: u64) -> Self {
179        let binding = max_storage_buffer_binding_size as usize;
180        Self {
181            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
182                .clamp(1, MAX_SHAPES_PER_STORAGE_BATCH),
183            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
184                .clamp(1, MAX_GRADIENT_STOPS_PER_STORAGE_BATCH),
185            storage: true,
186        }
187    }
188
189    fn initial_shape_capacity(&self) -> usize {
190        #[cfg(not(target_arch = "wasm32"))]
191        if self.storage {
192            return self
193                .max_shapes_per_batch
194                .min(INITIAL_STORAGE_BATCH_CAPACITY);
195        }
196        self.max_shapes_per_batch
197    }
198
199    fn initial_gradient_capacity(&self) -> usize {
200        #[cfg(not(target_arch = "wasm32"))]
201        if self.storage {
202            return self.max_gradient_stops.min(INITIAL_STORAGE_BATCH_CAPACITY);
203        }
204        self.max_gradient_stops
205    }
206
207    fn data_buffer_usage(&self) -> wgpu::BufferUsages {
208        if self.storage {
209            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
210        } else {
211            wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
212        }
213    }
214
215    fn data_binding_type(&self) -> wgpu::BufferBindingType {
216        if self.storage {
217            wgpu::BufferBindingType::Storage { read_only: true }
218        } else {
219            wgpu::BufferBindingType::Uniform
220        }
221    }
222
223    #[cfg(test)]
224    fn desktop() -> Self {
225        Self::for_uniform_binding_size(wgpu::Limits::default().max_uniform_buffer_binding_size)
226    }
227}
228#[cfg(target_arch = "wasm32")]
229const HARD_MAX_BUFFER_MB: usize = 64; // Maximum 64MB per buffer (image vertex/index only)
230const MAX_SHADOW_SURFACE_CACHE_ITEMS: usize = 512;
231// Sized for HiDPI: a 4K fractional-scale screen full of shadowed panels needs
232// ~10-15 rasters of 4-12MB each; a 64MB budget made the large entries evict
233// each other every frame during scroll, re-blurring tens of megapixels.
234const MAX_SHADOW_SURFACE_CACHE_BYTES: u64 = 192 * 1024 * 1024;
235const MAX_TEXT_IMAGE_CACHE_ITEMS: usize = 1024;
236const MAX_TEXT_GLYPH_MASK_CACHE_ITEMS: usize = 8192;
237const MAX_TEXT_GLYPH_ATLAS_ITEMS: usize = 8192;
238const MAX_TEXT_GLYPH_RUN_CACHE_ITEMS: usize = 1024;
239#[cfg(not(target_arch = "wasm32"))]
240const MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS: usize = 1024;
241#[cfg(not(target_arch = "wasm32"))]
242const MIN_RETAINED_TEXT_GLYPH_QUADS: usize = 192;
243#[cfg(not(target_arch = "wasm32"))]
244const OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS: f64 = 0.75;
245#[cfg(not(target_arch = "wasm32"))]
246const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES: usize = 2;
247#[cfg(not(target_arch = "wasm32"))]
248const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS: usize = 160;
249#[cfg(not(target_arch = "wasm32"))]
250const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS: usize = 160;
251/// Side length the glyph atlas starts at, and the one it doubles towards.
252///
253/// The atlas is square and `R8Unorm`, so the maximum is a 16 MiB texture. That
254/// was also the starting size until it became the single largest resource the
255/// renderer allocated: a 454x454 watch face draws a couple of hundred distinct
256/// glyphs and needs well under a megabyte of them, but paid the full 16 MiB at
257/// renderer construction, before a single glyph had been rastered. Starting at
258/// `MIN` and doubling on overflow (see `TextGlyphAtlas::reset`) costs at most
259/// three extra resets for a workload that genuinely needs the large atlas —
260/// which then behaves exactly as the fixed 4096 atlas did — and costs a
261/// text-light screen 256 KiB instead of 16 MiB, permanently.
262const TEXT_GLYPH_ATLAS_MIN_SIZE: u32 = 512;
263const TEXT_GLYPH_ATLAS_MAX_SIZE: u32 = 4096;
264const TEXT_GLYPH_ATLAS_PADDING: u32 = 1;
265const MAX_TEXT_LINE_INDEX_CACHE_ITEMS: usize = 512;
266const MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER: usize = 2;
267const MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES: usize = 128;
268const CACHE_MISS_WARMUP_FRAMES: u8 = 1;
269pub(crate) const CLEAR_COLOR: wgpu::Color = wgpu::Color {
270    r: cranpose_render_common::FRAME_CLEAR_COLOR[0] as f64,
271    g: cranpose_render_common::FRAME_CLEAR_COLOR[1] as f64,
272    b: cranpose_render_common::FRAME_CLEAR_COLOR[2] as f64,
273    a: cranpose_render_common::FRAME_CLEAR_COLOR[3] as f64,
274};
275#[cfg(not(target_arch = "wasm32"))]
276const INITIAL_UPLOAD_BUFFER_BYTES: u64 = 4 * 1024;
277#[cfg(not(target_arch = "wasm32"))]
278const INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS: usize = 128;
279const MAX_TEXTURE_CACHE_ITEMS: usize = 256;
280/// Byte ceiling for `image_texture_cache` (see `CachedImageTexture::bytes`).
281/// Generous enough for a screenful of full-page images plus thumbnails;
282/// small enough that a camera preview stream can never pin gigabytes.
283const MAX_IMAGE_TEXTURE_CACHE_BYTES: usize = 256 * 1024 * 1024;
284const RETAINED_STAGED_UPLOAD_BYTES: usize = 256 * 1024;
285const RETAINED_STAGED_UPLOAD_COPIES: usize = 128;
286pub(crate) const RETAINED_LAYER_REQUIREMENTS_CAPACITY: usize = 512;
287const DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
288#[cfg(not(target_arch = "wasm32"))]
289static SEGMENT_DIAG_LINES: AtomicUsize = AtomicUsize::new(0);
290// Reclaim oversized text scratch allocations only after a meaningful 4x collapse
291// from a previously large frame; smaller swings are left alone to avoid churn.
292
293fn wgpu_render_stage_telemetry_threshold_ms() -> Option<f64> {
294    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
295    *THRESHOLD_MS.get_or_init(|| {
296        let explicit = std::env::var("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY_MS")
297            .ok()
298            .and_then(|value| value.parse::<f64>().ok())
299            .filter(|value| value.is_finite() && *value >= 0.0);
300        explicit.or_else(|| {
301            std::env::var_os("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY")
302                .is_some()
303                .then_some(DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS)
304        })
305    })
306}
307
308pub(crate) fn instant_ms(start: Instant, end: Instant) -> f64 {
309    end.duration_since(start).as_secs_f64() * 1000.0
310}
311
312pub(crate) fn should_log_wgpu_render_stage(start: Instant, end: Instant) -> Option<f64> {
313    let threshold_ms = wgpu_render_stage_telemetry_threshold_ms()?;
314    let total_ms = instant_ms(start, end);
315    (total_ms >= threshold_ms).then_some(total_ms)
316}
317
318fn admit_layer_surface_cache_miss_impl(
319    key: &LayerRasterCacheKey,
320    observed_scene_range_misses: &mut BoundedLruCache<LayerRasterCacheKey, ()>,
321) -> bool {
322    if !key.is_scene_range() {
323        return true;
324    }
325    if observed_scene_range_misses.contains(key) {
326        return true;
327    }
328    observed_scene_range_misses.put(*key, ());
329    false
330}
331
332#[cfg(test)]
333fn first_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
334    let mut observed_scene_range_misses =
335        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
336    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
337}
338
339#[cfg(test)]
340fn repeated_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
341    let mut observed_scene_range_misses =
342        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
343    let _ = admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses);
344    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
345}
346
347pub static PRESENTED_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
348
349pub fn frames_presented() -> u64 {
350    PRESENTED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
351}
352
353fn frame_stats_need_warmup_frame(snapshot: &gpu_stats::FrameStatsSnapshot) -> bool {
354    snapshot.layer_cache_misses > 0
355        || snapshot.shadow_shape_cache_misses > 0
356        || snapshot.text_image_cache_misses > 0
357        || snapshot.text_glyph_atlas_misses > 0
358}
359
360fn update_frame_warmup_budget(pending_frames: &mut u8, snapshot: &gpu_stats::FrameStatsSnapshot) {
361    if *pending_frames > 0 {
362        *pending_frames = pending_frames.saturating_sub(1);
363    } else if frame_stats_need_warmup_frame(snapshot) {
364        *pending_frames = CACHE_MISS_WARMUP_FRAMES;
365    }
366}
367
368fn text_atlas_fallback_diag_enabled() -> bool {
369    cranpose_core::env_flag!("CRANPOSE_TEXT_ATLAS_FALLBACK_DIAG")
370}
371
372fn text_glyph_run_diag_enabled() -> bool {
373    cranpose_core::env_flag!("CRANPOSE_TEXT_GLYPH_RUN_DIAG")
374}
375
376fn root_direct_diag_enabled() -> bool {
377    cranpose_core::env_flag!("CRANPOSE_ROOT_DIRECT_DIAG")
378}
379
380fn scene_layer_events_precede_z(scene: &CompositorScene, z_index: usize) -> bool {
381    scene
382        .effect_layers
383        .iter()
384        .any(|layer| layer.z_start < z_index && 0 < layer.z_end)
385        || scene
386            .backdrop_layers
387            .iter()
388            .any(|layer| layer.z_index < z_index)
389}
390
391fn direct_root_child_can_be_replayed_into_later_underlay(child: &ChildLayerComposite) -> bool {
392    child.backdrop.is_none()
393        && !child.has_effect
394        && child.shadow_draws.is_empty()
395        && axis_aligned_quad_rect(child.dest_quad).is_some()
396}
397
398fn rects_overlap(a: Rect, b: Rect) -> bool {
399    let a_right = a.x + a.width;
400    let a_bottom = a.y + a.height;
401    let b_right = b.x + b.width;
402    let b_bottom = b.y + b.height;
403    a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
404}
405
406pub(crate) fn direct_root_child_underlays_are_supported(collected: &CollectedLayer) -> bool {
407    for (child_index, child) in collected.child_layers.iter().enumerate() {
408        if child.backdrop.is_some() {
409            if root_direct_diag_enabled() {
410                log::warn!(
411                    "[root-direct-diag] reject self-backdrop child node={:?}",
412                    child.node_id
413                );
414            }
415            return false;
416        }
417        if child.needs_nested_underlay {
418            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
419                if root_direct_diag_enabled() {
420                    log::warn!(
421                        "[root-direct-diag] reject projective underlay child node={:?}",
422                        child.node_id
423                    );
424                }
425                return false;
426            };
427            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
428                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
429            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
430                .iter()
431                .any(|preceding| {
432                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
433                        return false;
434                    }
435                    axis_aligned_quad_rect(preceding.dest_quad)
436                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
437                });
438            let preceding_scene_events =
439                scene_layer_events_precede_z(&collected.scene, child.z_index);
440            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
441                if root_direct_diag_enabled() {
442                    log::warn!(
443                        "[root-direct-diag] reject underlay child node={:?} unsupported_preceding_child_layer={} preceding_scene_events={} translation_only={} dest=({:.1},{:.1},{:.1},{:.1}) logical=({:.1},{:.1},{:.1},{:.1})",
444                        child.node_id,
445                        unsupported_preceding_child_layer,
446                        preceding_scene_events,
447                        translation_only,
448                        dest_rect.x,
449                        dest_rect.y,
450                        dest_rect.width,
451                        dest_rect.height,
452                        child.logical_rect.x,
453                        child.logical_rect.y,
454                        child.logical_rect.width,
455                        child.logical_rect.height
456                    );
457                }
458                return false;
459            }
460        }
461    }
462    true
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
466struct ShadowSurfaceCacheKey {
467    content_hash: u64,
468    pixel_size: [u32; 2],
469    root_scale_bits: u32,
470    blur_radius_bits: u32,
471}
472
473struct CachedShadowSurface {
474    target: Rc<OffscreenTarget>,
475    byte_size: u64,
476}
477
478struct CachedShadowComposite {
479    source: Rc<OffscreenTarget>,
480    scissor: Option<(u32, u32, u32, u32)>,
481    rounded_mask: Option<RoundedCompositeMask>,
482    dest_viewport: Option<(f32, f32, f32, f32)>,
483}
484
485impl CachedShadowComposite {
486    fn batch_item(&self) -> CompositeBatchItem<'_> {
487        CompositeBatchItem {
488            source: &self.source,
489            alpha: 1.0,
490            scissor: self.scissor,
491            rounded_mask: self.rounded_mask,
492            blend_mode: BlendMode::SrcOver,
493            dest_viewport: self.dest_viewport,
494            source_viewport: None,
495            sample_mode: CompositeSampleMode::Nearest,
496        }
497    }
498}
499
500#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
501struct TextImageCacheKey(u64);
502
503struct CachedTextImage {
504    image: ImageBitmap,
505}
506
507#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
508struct TextGlyphRunCacheKey(u64);
509
510#[derive(Clone, Copy)]
511struct CachedTextGlyphQuad {
512    x: i32,
513    y: i32,
514    width: usize,
515    height: usize,
516    color: (f32, f32, f32, f32),
517    uv: ImageUvRect,
518}
519
520struct CachedTextGlyphRun {
521    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
522    quads: Option<Rc<[CachedTextGlyphQuad]>>,
523    atlas_generation: u64,
524}
525
526const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
527
528#[cfg(not(target_arch = "wasm32"))]
529struct CachedGpuTextGlyphRun {
530    vertex_buffer: wgpu::Buffer,
531    index_buffer: wgpu::Buffer,
532    index_count: u32,
533    atlas_generation: u64,
534}
535
536#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
537struct TextLineIndexCacheKey(usize);
538
539struct CachedTextLineIndex {
540    text: std::sync::Weak<cranpose_ui::text::RenderString>,
541    len: usize,
542    starts: Rc<[usize]>,
543}
544
545struct TextLineIndexCache {
546    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
547}
548
549impl TextLineIndexCache {
550    fn new(capacity: usize) -> Self {
551        Self {
552            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
553        }
554    }
555
556    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
557        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
558        if let Some(cached) = self.entries.get(&key) {
559            if cached.len == text.text.len()
560                && cached
561                    .text
562                    .upgrade()
563                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
564            {
565                return cached.starts.clone();
566            }
567        }
568
569        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
570        self.entries.put(
571            key,
572            CachedTextLineIndex {
573                text: Arc::downgrade(text),
574                len: text.text.len(),
575                starts: starts.clone(),
576            },
577        );
578        starts
579    }
580}
581
582#[derive(Clone, Copy, Debug, PartialEq)]
583struct ShapeShadowSurfacePlan {
584    source_device_bounds: DevicePixelBounds,
585    processing_scissor: Option<(u32, u32, u32, u32)>,
586    pixel_radius: f32,
587}
588
589#[derive(Default)]
590struct RendererWarningState {
591    unsupported_effect_reported: Cell<bool>,
592}
593
594impl RendererWarningState {
595    fn warn_unsupported_effect_once(&self) {
596        if !self.unsupported_effect_reported.replace(true) {
597            log::warn!(
598                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
599            );
600        }
601    }
602}
603
604fn is_blend_mode_supported(mode: BlendMode) -> bool {
605    matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
606}
607
608fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
609    match mode {
610        BlendMode::DstOut => wgpu::BlendState {
611            color: wgpu::BlendComponent {
612                src_factor: wgpu::BlendFactor::Zero,
613                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
614                operation: wgpu::BlendOperation::Add,
615            },
616            alpha: wgpu::BlendComponent {
617                src_factor: wgpu::BlendFactor::Zero,
618                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
619                operation: wgpu::BlendOperation::Add,
620            },
621        },
622        _ => wgpu::BlendState::ALPHA_BLENDING,
623    }
624}
625
626fn supported_blend_mode(mode: BlendMode) -> BlendMode {
627    if is_blend_mode_supported(mode) {
628        return mode;
629    }
630
631    BlendMode::SrcOver
632}
633
634fn direct_shader_composite_viewport(
635    alpha: f32,
636    blend_mode: BlendMode,
637    dest_viewport: Option<(f32, f32, f32, f32)>,
638    sample_mode: CompositeSampleMode,
639    source_size: (u32, u32),
640) -> Option<(f32, f32, f32, f32)> {
641    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
642        return None;
643    }
644    let viewport = dest_viewport?;
645    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
646        return None;
647    }
648    match sample_mode {
649        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
650        CompositeSampleMode::Box4
651            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
652        {
653            Some(viewport)
654        }
655        CompositeSampleMode::Box4 => None,
656    }
657}
658
659fn shader_composite_preserves_source_pixel_grid(
660    viewport: (f32, f32, f32, f32),
661    source_size: (u32, u32),
662) -> bool {
663    const EPSILON: f32 = 0.01;
664    let (x, y, width, height) = viewport;
665    let (source_width, source_height) = source_size;
666    (x - x.round()).abs() <= EPSILON
667        && (y - y.round()).abs() <= EPSILON
668        && (width - source_width as f32).abs() <= EPSILON
669        && (height - source_height as f32).abs() <= EPSILON
670}
671
672type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
673
674fn direct_shader_tail_composite(
675    effect: &RenderEffect,
676    alpha: f32,
677    blend_mode: BlendMode,
678    dest_viewport: Option<(f32, f32, f32, f32)>,
679    sample_mode: CompositeSampleMode,
680    source_size: (u32, u32),
681) -> Option<DirectShaderTailComposite<'_>> {
682    let viewport = direct_shader_composite_viewport(
683        alpha,
684        blend_mode,
685        dest_viewport,
686        sample_mode,
687        source_size,
688    )?;
689    let RenderEffect::Chain { first, second } = effect else {
690        return None;
691    };
692    let RenderEffect::Shader { shader } = second.as_ref() else {
693        return None;
694    };
695    Some((first.as_ref(), shader, viewport))
696}
697
698fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
699    value.to_bits().hash(state);
700}
701
702fn hash_text_raster_geometry_for_cache<H: Hasher>(
703    rect: Rect,
704    static_text_motion: bool,
705    state: &mut H,
706) {
707    hash_f32_for_cache(rect.width, state);
708    hash_f32_for_cache(rect.height, state);
709    static_text_motion.hash(state);
710    if !static_text_motion {
711        hash_f32_for_cache(rect.x.fract(), state);
712        hash_f32_for_cache(rect.y.fract(), state);
713    }
714}
715
716fn text_raster_geometry_for_draw(
717    text_draw: &TextDraw,
718    root_scale: f32,
719) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
720    if text_draw.text.is_empty()
721        || text_draw.rect.width <= 0.0
722        || text_draw.rect.height <= 0.0
723        || !root_scale.is_finite()
724        || root_scale <= 0.0
725    {
726        return None;
727    }
728
729    let text_scale = text_draw.scale * root_scale;
730    if !text_scale.is_finite() || text_scale <= 0.0 {
731        return None;
732    }
733
734    let static_text_motion = text_draw
735        .text_style
736        .paragraph_style
737        .text_motion
738        .unwrap_or(cranpose_ui::text::TextMotion::Static)
739        == cranpose_ui::text::TextMotion::Static;
740    let snap_delta = text_draw
741        .snap_anchor
742        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
743        .unwrap_or_default();
744    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
745    // Clips are resolved in scene space from their own layer ancestry. A draw
746    // item's raster snap must never move a fixed ancestor clip.
747    let clip = text_draw.clip;
748    let mut raster_rect = Rect {
749        x: logical_rect.x * root_scale,
750        y: logical_rect.y * root_scale,
751        width: logical_rect.width * root_scale,
752        height: logical_rect.height * root_scale,
753    };
754    if text_draw.snap_anchor.is_some() {
755        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
756        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
757    }
758    if static_text_motion {
759        raster_rect.x = raster_rect.x.round();
760        raster_rect.y = raster_rect.y.round();
761    }
762    raster_rect.width = raster_rect.width.ceil().max(1.0);
763    raster_rect.height = raster_rect.height.ceil().max(1.0);
764    Some((
765        logical_rect,
766        raster_rect,
767        clip,
768        text_scale,
769        static_text_motion,
770    ))
771}
772
773fn text_draw_is_visible_in_viewport(
774    logical_rect: Rect,
775    clip: Option<Rect>,
776    viewport: ViewportUniformParams,
777    root_scale: f32,
778) -> bool {
779    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
780}
781
782fn text_draw_should_prewarm_in_viewport(
783    logical_rect: Rect,
784    clip: Option<Rect>,
785    viewport: ViewportUniformParams,
786    root_scale: f32,
787) -> bool {
788    if !root_scale.is_finite() || root_scale <= 0.0 {
789        return false;
790    }
791    let viewport_rect = Rect {
792        x: viewport.offset[0] / root_scale,
793        y: viewport.offset[1] / root_scale,
794        width: viewport.width as f32 / root_scale,
795        height: viewport.height as f32 / root_scale,
796    };
797    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
798    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
799    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
800    let prewarm_rect = match clip {
801        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
802        None => Some(prewarm_viewport),
803    };
804    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
805}
806
807fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
808    Rect {
809        x: rect.x - margin_x,
810        y: rect.y - margin_y,
811        width: rect.width + margin_x * 2.0,
812        height: rect.height + margin_y * 2.0,
813    }
814}
815
816fn draw_rect_is_visible_in_viewport(
817    rect: Rect,
818    clip: Option<Rect>,
819    viewport: ViewportUniformParams,
820    root_scale: f32,
821) -> bool {
822    if !root_scale.is_finite() || root_scale <= 0.0 {
823        return false;
824    }
825    let viewport_rect = Rect {
826        x: viewport.offset[0] / root_scale,
827        y: viewport.offset[1] / root_scale,
828        width: viewport.width as f32 / root_scale,
829        height: viewport.height as f32 / root_scale,
830    };
831    let visible_rect = match clip {
832        Some(clip) => clip.intersect(viewport_rect),
833        None => Some(viewport_rect),
834    };
835    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
836}
837
838fn shape_draw_is_visible_in_viewport(
839    shape: &DrawShape,
840    viewport: ViewportUniformParams,
841    root_scale: f32,
842) -> bool {
843    let snap_delta = shape
844        .snap_anchor
845        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
846        .unwrap_or_default();
847    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
848    let clip = shape.clip;
849    draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale)
850}
851
852fn cached_text_glyph_quad(
853    glyph: &SoftwareGlyphAtlasPlacement,
854    entry: GlyphAtlasEntry,
855    atlas_size: u32,
856) -> CachedTextGlyphQuad {
857    CachedTextGlyphQuad {
858        x: glyph.x,
859        y: glyph.y,
860        width: glyph.width,
861        height: glyph.height,
862        color: (
863            glyph.color.0.clamp(0.0, 1.0),
864            glyph.color.1.clamp(0.0, 1.0),
865            glyph.color.2.clamp(0.0, 1.0),
866            glyph.color.3.clamp(0.0, 1.0),
867        ),
868        uv: glyph_atlas_uv_rect(entry, atlas_size),
869    }
870}
871
872fn append_cached_text_glyph_quad(
873    source_raster_rect: Rect,
874    quad: &CachedTextGlyphQuad,
875    image_vertices: &mut Vec<Vertex>,
876    image_indices: &mut Vec<u32>,
877) -> bool {
878    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
879        return false;
880    }
881
882    let base_vertex = image_vertices.len() as u32;
883    image_indices.extend_from_slice(&[
884        base_vertex,
885        base_vertex + 1,
886        base_vertex + 2,
887        base_vertex + 2,
888        base_vertex + 1,
889        base_vertex + 3,
890    ]);
891
892    let x0 = source_raster_rect.x + quad.x as f32;
893    let y0 = source_raster_rect.y + quad.y as f32;
894    let x1 = x0 + quad.width as f32;
895    let y1 = y0 + quad.height as f32;
896    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
897
898    image_vertices.extend_from_slice(&[
899        Vertex {
900            position: [x0, y0],
901            color,
902            uv: [quad.uv.min[0], quad.uv.min[1]],
903            uv_bounds: quad.uv.sample_bounds,
904        },
905        Vertex {
906            position: [x1, y0],
907            color,
908            uv: [quad.uv.max[0], quad.uv.min[1]],
909            uv_bounds: quad.uv.sample_bounds,
910        },
911        Vertex {
912            position: [x0, y1],
913            color,
914            uv: [quad.uv.min[0], quad.uv.max[1]],
915            uv_bounds: quad.uv.sample_bounds,
916        },
917        Vertex {
918            position: [x1, y1],
919            color,
920            uv: [quad.uv.max[0], quad.uv.max[1]],
921            uv_bounds: quad.uv.sample_bounds,
922        },
923    ]);
924    true
925}
926
927fn cached_text_glyph_quad_logical_rect(
928    source_raster_rect: Rect,
929    quad: &CachedTextGlyphQuad,
930    root_scale: f32,
931) -> Option<Rect> {
932    if !root_scale.is_finite() || root_scale <= 0.0 {
933        return None;
934    }
935    Some(Rect {
936        x: (source_raster_rect.x + quad.x as f32) / root_scale,
937        y: (source_raster_rect.y + quad.y as f32) / root_scale,
938        width: quad.width as f32 / root_scale,
939        height: quad.height as f32 / root_scale,
940    })
941}
942
943fn cached_text_glyph_quad_is_visible_in_viewport(
944    source_raster_rect: Rect,
945    quad: &CachedTextGlyphQuad,
946    clip: Option<Rect>,
947    viewport: ViewportUniformParams,
948    root_scale: f32,
949) -> bool {
950    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
951        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
952}
953
954#[derive(Clone, Copy, Debug, Eq, PartialEq)]
955enum TextGlyphDrawAction {
956    DrawVisible,
957    PrewarmOffscreen,
958    Skip,
959}
960
961fn text_glyph_draw_action(
962    is_visible: bool,
963    is_prewarm_candidate: bool,
964    allow_offscreen_prewarm: bool,
965) -> TextGlyphDrawAction {
966    if is_visible {
967        TextGlyphDrawAction::DrawVisible
968    } else if allow_offscreen_prewarm && is_prewarm_candidate {
969        TextGlyphDrawAction::PrewarmOffscreen
970    } else {
971        TextGlyphDrawAction::Skip
972    }
973}
974
975#[cfg(not(target_arch = "wasm32"))]
976fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
977    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
978}
979
980#[cfg(not(target_arch = "wasm32"))]
981fn offscreen_text_glyph_prewarm_work_is_bounded(
982    cached_glyphs: Option<usize>,
983    text_len: usize,
984) -> bool {
985    match cached_glyphs {
986        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
987        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
988    }
989}
990
991#[cfg(not(target_arch = "wasm32"))]
992fn offscreen_text_glyph_prewarm_budget_exhausted(
993    start: Instant,
994    admitted_candidates: usize,
995) -> bool {
996    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
997        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
998}
999
1000fn text_draws_for_ordered_range<'a>(
1001    ordered_items: &'a [(usize, SegmentDrawItem)],
1002    texts: &'a [TextDraw],
1003    start: usize,
1004    end: usize,
1005) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1006    let range_items = ordered_items
1007        .get(start..end)
1008        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1009    for (_, item) in range_items {
1010        match item {
1011            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1012            SegmentDrawItem::Text(text_index) => {
1013                return Err(format!(
1014                    "text batch references missing text draw index: {text_index}"
1015                ));
1016            }
1017            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1018        }
1019    }
1020
1021    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1022        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1023        _ => None,
1024    }))
1025}
1026
1027/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1028/// translations reuse the cached blurred raster. The cached surface is
1029/// composited one-to-one with texel-exact sampling; translation may not change
1030/// either the blur or its sampling phase.
1031const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1032
1033fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1034    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1035    (quantized as i64).hash(state);
1036}
1037
1038fn hash_shadow_device_rect<H: Hasher>(
1039    rect: Rect,
1040    origin_x: f32,
1041    origin_y: f32,
1042    root_scale: f32,
1043    state: &mut H,
1044) {
1045    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1046    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1047    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1048    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1049}
1050
1051fn hash_shape_shadow_item<H: Hasher>(
1052    shape: &DrawShape,
1053    brushes: &[Brush],
1054    blend_mode: BlendMode,
1055    origin_x: f32,
1056    origin_y: f32,
1057    root_scale: f32,
1058    state: &mut H,
1059) {
1060    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1061    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1062    for point in shape.quad {
1063        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1064        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1065    }
1066    match shape.snap_anchor {
1067        Some(anchor) => {
1068            1u8.hash(state);
1069            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1070            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1071            hash_f32_for_cache(anchor.device_pixel_step, state);
1072        }
1073        None => 0u8.hash(state),
1074    }
1075    shape.brush.render_hash(brushes).hash(state);
1076    match shape.shape {
1077        Some(corner_shape) => {
1078            1u8.hash(state);
1079            corner_shape.radii().render_hash().hash(state);
1080        }
1081        None => 0u8.hash(state),
1082    }
1083    match shape.clip {
1084        Some(clip) => {
1085            1u8.hash(state);
1086            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1087        }
1088        None => 0u8.hash(state),
1089    }
1090    blend_mode.hash(state);
1091    shape.blend_mode.hash(state);
1092}
1093
1094fn shape_shadow_content_hash(
1095    shapes: &[(DrawShape, BlendMode)],
1096    brushes: &[Brush],
1097    root_scale: f32,
1098) -> u64 {
1099    let mut hasher = FxHasher::default();
1100    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1101    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1102    // the device subpixel phase into the hash and defeat the cache at
1103    // fractional display scales.
1104    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1105        x: 0.0,
1106        y: 0.0,
1107        width: 0.0,
1108        height: 0.0,
1109    });
1110
1111    shapes.len().hash(&mut hasher);
1112    for (shape, blend_mode) in shapes {
1113        hash_shape_shadow_item(
1114            shape,
1115            brushes,
1116            *blend_mode,
1117            origin.x,
1118            origin.y,
1119            root_scale,
1120            &mut hasher,
1121        );
1122    }
1123    hasher.finish()
1124}
1125
1126fn shape_shadow_surface_cache_key(
1127    shapes: &[(DrawShape, BlendMode)],
1128    brushes: &[Brush],
1129    device_bounds: DevicePixelBounds,
1130    pixel_radius: f32,
1131    root_scale: f32,
1132) -> Option<ShadowSurfaceCacheKey> {
1133    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1134        content_hash: shape_shadow_content_hash(shapes, brushes, root_scale),
1135        pixel_size: [device_bounds.width, device_bounds.height],
1136        root_scale_bits: root_scale.to_bits(),
1137        blur_radius_bits: pixel_radius.to_bits(),
1138    })
1139}
1140
1141fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1142    shapes
1143        .iter()
1144        .map(|(shape, _)| shape.rect)
1145        .reduce(|a, b| Rect {
1146            x: a.x.min(b.x),
1147            y: a.y.min(b.y),
1148            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1149            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1150        })
1151}
1152
1153fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1154    let anchor = shapes.first()?.0.snap_anchor?;
1155    shapes
1156        .iter()
1157        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1158        .then_some(anchor)
1159}
1160
1161fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1162    shadow
1163        .shapes
1164        .iter()
1165        .map(|(shape, _)| shape.rect)
1166        .chain(shadow.texts.iter().map(|text| text.rect))
1167        .reduce(|a, b| Rect {
1168            x: a.x.min(b.x),
1169            y: a.y.min(b.y),
1170            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1171            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1172        })
1173}
1174
1175fn shadow_draw_may_render(
1176    shadow: &ShadowDraw,
1177    width: u32,
1178    height: u32,
1179    root_scale: f32,
1180    max_texture_dim: u32,
1181) -> bool {
1182    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1183        return shape_shadow_surface_plan(
1184            &shadow.shapes,
1185            shadow.clip,
1186            shadow.blur_radius,
1187            width,
1188            height,
1189            root_scale,
1190            max_texture_dim,
1191        )
1192        .is_some();
1193    }
1194
1195    let Some(bounds) = shadow_draw_bounds(shadow) else {
1196        return false;
1197    };
1198    let blur_margin = blur_extent_margin(shadow.blur_radius);
1199    let mut visible_bounds = Rect {
1200        x: bounds.x - blur_margin,
1201        y: bounds.y - blur_margin,
1202        width: bounds.width + blur_margin * 2.0,
1203        height: bounds.height + blur_margin * 2.0,
1204    };
1205    if let Some(clip) = shadow.clip {
1206        let clip_expanded = Rect {
1207            x: clip.x - blur_margin,
1208            y: clip.y - blur_margin,
1209            width: clip.width + blur_margin * 2.0,
1210            height: clip.height + blur_margin * 2.0,
1211        };
1212        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1213            return false;
1214        };
1215        visible_bounds = intersection;
1216    }
1217
1218    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1219}
1220
1221fn shape_shadow_surface_plan(
1222    shapes: &[(DrawShape, BlendMode)],
1223    clip: Option<Rect>,
1224    blur_radius: f32,
1225    width: u32,
1226    height: u32,
1227    root_scale: f32,
1228    max_texture_dim: u32,
1229) -> Option<ShapeShadowSurfacePlan> {
1230    let shape_bounds = shape_shadow_bounds(shapes)?;
1231    let blur_margin = blur_extent_margin(blur_radius);
1232    let source_blur_bounds = Rect {
1233        x: shape_bounds.x - blur_margin,
1234        y: shape_bounds.y - blur_margin,
1235        width: shape_bounds.width + blur_margin * 2.0,
1236        height: shape_bounds.height + blur_margin * 2.0,
1237    };
1238
1239    let mut visible_blur_bounds = source_blur_bounds;
1240    if let Some(clip) = clip {
1241        let clip_expanded = Rect {
1242            x: clip.x - blur_margin,
1243            y: clip.y - blur_margin,
1244            width: clip.width + blur_margin * 2.0,
1245            height: clip.height + blur_margin * 2.0,
1246        };
1247        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1248    }
1249
1250    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1251    processing_scissor?;
1252    let visible_device_bounds =
1253        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1254    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1255        source_blur_bounds,
1256        shared_shape_shadow_snap_anchor(shapes),
1257        root_scale,
1258        max_texture_dim,
1259    )
1260    .unwrap_or(visible_device_bounds);
1261
1262    Some(ShapeShadowSurfacePlan {
1263        source_device_bounds,
1264        processing_scissor,
1265        pixel_radius: blur_radius * root_scale,
1266    })
1267}
1268
1269fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1270    match effect {
1271        RenderEffect::Blur { .. } => true,
1272        RenderEffect::Offset { .. } => true,
1273        RenderEffect::Shader { .. } => true,
1274        RenderEffect::Chain { first, second } => {
1275            is_render_effect_supported(first) && is_render_effect_supported(second)
1276        }
1277    }
1278}
1279
1280fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1281    if value.is_finite() {
1282        origin + value
1283    } else if value.is_sign_positive() {
1284        origin + extent
1285    } else {
1286        origin
1287    }
1288}
1289
1290fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1291    match tile_mode {
1292        TileMode::Clamp => 0,
1293        TileMode::Repeated => 1,
1294        TileMode::Mirror => 2,
1295        TileMode::Decal => 3,
1296    }
1297}
1298
1299#[cfg(not(target_arch = "wasm32"))]
1300fn shape_shader_source(batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1301    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1302    // the substitution silently no-op and leaves the shader sized for the
1303    // downlevel floor.
1304    if batch_limits.storage {
1305        return Cow::Owned(
1306            shaders::SHADER
1307                .replace(
1308                    "var<uniform> shape_data: array<ShapeData, 102>;",
1309                    "var<storage, read> shape_data: array<ShapeData>;",
1310                )
1311                .replace(
1312                    "var<uniform> gradient_stops: array<GradientStop, 256>;",
1313                    // Also inject the retained-paint array here: one mutable
1314                    // color per shape, read when `similarity.paint_select`
1315                    // is set, so recolor patches upload 16-byte colors
1316                    // instead of whole ShapeData records. The base text
1317                    // never declares it — uniform-mode devices cannot bind
1318                    // storage and never host retained slots.
1319                    "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1320                     @group(1) @binding(3)\n\
1321                     var<storage, read> paint: array<vec4<f32>>;",
1322                )
1323                .replace(
1324                    "output.color = shape.color;",
1325                    "output.color = \
1326                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1327                ),
1328        );
1329    }
1330    Cow::Owned(
1331        shaders::SHADER
1332            .replace(
1333                "array<ShapeData, 102>",
1334                &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1335            )
1336            .replace(
1337                "array<GradientStop, 256>",
1338                &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1339            ),
1340    )
1341}
1342
1343#[cfg(target_arch = "wasm32")]
1344fn shape_shader_source(_batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1345    Cow::Borrowed(shaders::SHADER)
1346}
1347
1348fn create_shape_pipeline(
1349    device: &wgpu::Device,
1350    surface_format: wgpu::TextureFormat,
1351    uniform_layout: &wgpu::BindGroupLayout,
1352    shape_layout: &wgpu::BindGroupLayout,
1353    blend_mode: BlendMode,
1354    batch_limits: ShapeBatchLimits,
1355    fragment_entry: &'static str,
1356) -> wgpu::RenderPipeline {
1357    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1358        label: Some("Shape Shader"),
1359        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1360    });
1361
1362    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1363        label: Some("Render Pipeline Layout"),
1364        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1365        immediate_size: 0,
1366    });
1367
1368    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1369        label: Some("Render Pipeline"),
1370        layout: Some(&pipeline_layout),
1371        vertex: wgpu::VertexState {
1372            module: &shader,
1373            entry_point: Some("vs_main"),
1374            compilation_options: wgpu::PipelineCompilationOptions::default(),
1375            // No vertex buffer: `vs_main` pulls quad corners from ShapeData
1376            // by `vertex_index`.
1377            buffers: &[],
1378        },
1379        fragment: Some(wgpu::FragmentState {
1380            module: &shader,
1381            entry_point: Some(fragment_entry),
1382            compilation_options: wgpu::PipelineCompilationOptions::default(),
1383            targets: &[Some(wgpu::ColorTargetState {
1384                format: surface_format,
1385                blend: Some(blend_state_for_mode(blend_mode)),
1386                write_mask: wgpu::ColorWrites::ALL,
1387            })],
1388        }),
1389        primitive: wgpu::PrimitiveState {
1390            topology: wgpu::PrimitiveTopology::TriangleList,
1391            strip_index_format: None,
1392            front_face: wgpu::FrontFace::Ccw,
1393            cull_mode: None,
1394            unclipped_depth: false,
1395            polygon_mode: wgpu::PolygonMode::Fill,
1396            conservative: false,
1397        },
1398        depth_stencil: None,
1399        multisample: wgpu::MultisampleState::default(),
1400        multiview_mask: None,
1401        cache: None,
1402    })
1403}
1404
1405/// Storage-mode pipeline for retained slots that captured a conservative arc
1406/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1407/// expanding six corners per shape. Fragment stage, bind group layouts
1408/// (including the dynamic-offset similarity binding and the retained paint
1409/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1410/// path uses — only the vertex fetch differs.
1411#[cfg(not(target_arch = "wasm32"))]
1412fn create_mesh_shape_pipeline(
1413    device: &wgpu::Device,
1414    surface_format: wgpu::TextureFormat,
1415    uniform_layout: &wgpu::BindGroupLayout,
1416    shape_layout: &wgpu::BindGroupLayout,
1417    batch_limits: ShapeBatchLimits,
1418) -> wgpu::RenderPipeline {
1419    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1420        label: Some("Shape Mesh Shader"),
1421        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1422    });
1423
1424    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1425        label: Some("Mesh Render Pipeline Layout"),
1426        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1427        immediate_size: 0,
1428    });
1429
1430    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1431        label: Some("Retained Mesh Pipeline"),
1432        layout: Some(&pipeline_layout),
1433        vertex: wgpu::VertexState {
1434            module: &shader,
1435            entry_point: Some("vs_mesh"),
1436            compilation_options: wgpu::PipelineCompilationOptions::default(),
1437            buffers: &[MeshVertex::desc()],
1438        },
1439        fragment: Some(wgpu::FragmentState {
1440            module: &shader,
1441            entry_point: Some("fs_main"),
1442            compilation_options: wgpu::PipelineCompilationOptions::default(),
1443            targets: &[Some(wgpu::ColorTargetState {
1444                format: surface_format,
1445                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1446                write_mask: wgpu::ColorWrites::ALL,
1447            })],
1448        }),
1449        primitive: wgpu::PrimitiveState {
1450            topology: wgpu::PrimitiveTopology::TriangleList,
1451            strip_index_format: None,
1452            front_face: wgpu::FrontFace::Ccw,
1453            cull_mode: None,
1454            unclipped_depth: false,
1455            polygon_mode: wgpu::PolygonMode::Fill,
1456            conservative: false,
1457        },
1458        depth_stencil: None,
1459        multisample: wgpu::MultisampleState::default(),
1460        multiview_mask: None,
1461        cache: None,
1462    })
1463}
1464
1465/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1466/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1467/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1468/// unindexed corner expansions. Everything but the vertex entry point is
1469/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1470/// same blend per mode — so a draw-time fallback to `vs_main` (the
1471/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1472#[cfg(not(target_arch = "wasm32"))]
1473fn create_instanced_shape_pipeline(
1474    device: &wgpu::Device,
1475    surface_format: wgpu::TextureFormat,
1476    uniform_layout: &wgpu::BindGroupLayout,
1477    shape_layout: &wgpu::BindGroupLayout,
1478    blend_mode: BlendMode,
1479    batch_limits: ShapeBatchLimits,
1480    fragment_entry: &'static str,
1481) -> wgpu::RenderPipeline {
1482    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1483        label: Some("Shape Instanced Shader"),
1484        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1485    });
1486
1487    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1488        label: Some("Instanced Render Pipeline Layout"),
1489        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1490        immediate_size: 0,
1491    });
1492
1493    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1494        label: Some("Instanced Render Pipeline"),
1495        layout: Some(&pipeline_layout),
1496        vertex: wgpu::VertexState {
1497            module: &shader,
1498            entry_point: Some("vs_shape_instanced"),
1499            compilation_options: wgpu::PipelineCompilationOptions::default(),
1500            // No vertex buffer: like `vs_main`, the corners come from
1501            // ShapeData; only the shape index source differs
1502            // (`instance_index` instead of `vertex_index / 6`).
1503            buffers: &[],
1504        },
1505        fragment: Some(wgpu::FragmentState {
1506            module: &shader,
1507            entry_point: Some(fragment_entry),
1508            compilation_options: wgpu::PipelineCompilationOptions::default(),
1509            targets: &[Some(wgpu::ColorTargetState {
1510                format: surface_format,
1511                blend: Some(blend_state_for_mode(blend_mode)),
1512                write_mask: wgpu::ColorWrites::ALL,
1513            })],
1514        }),
1515        primitive: wgpu::PrimitiveState {
1516            topology: wgpu::PrimitiveTopology::TriangleList,
1517            strip_index_format: None,
1518            front_face: wgpu::FrontFace::Ccw,
1519            cull_mode: None,
1520            unclipped_depth: false,
1521            polygon_mode: wgpu::PolygonMode::Fill,
1522            conservative: false,
1523        },
1524        depth_stencil: None,
1525        multisample: wgpu::MultisampleState::default(),
1526        multiview_mask: None,
1527        cache: None,
1528    })
1529}
1530
1531fn create_image_pipeline(
1532    device: &wgpu::Device,
1533    surface_format: wgpu::TextureFormat,
1534    uniform_layout: &wgpu::BindGroupLayout,
1535    image_layout: &wgpu::BindGroupLayout,
1536    blend_mode: BlendMode,
1537) -> wgpu::RenderPipeline {
1538    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1539        label: Some("Image Shader"),
1540        source: wgpu::ShaderSource::Wgsl(shaders::IMAGE_SHADER.into()),
1541    });
1542
1543    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1544        label: Some("Image Pipeline Layout"),
1545        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1546        immediate_size: 0,
1547    });
1548
1549    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1550        label: Some("Image Pipeline"),
1551        layout: Some(&image_pipeline_layout),
1552        vertex: wgpu::VertexState {
1553            module: &image_shader,
1554            entry_point: Some("image_vs_main"),
1555            compilation_options: wgpu::PipelineCompilationOptions::default(),
1556            buffers: &[Vertex::desc()],
1557        },
1558        fragment: Some(wgpu::FragmentState {
1559            module: &image_shader,
1560            entry_point: Some("image_fs_main"),
1561            compilation_options: wgpu::PipelineCompilationOptions::default(),
1562            targets: &[Some(wgpu::ColorTargetState {
1563                format: surface_format,
1564                blend: Some(blend_state_for_mode(blend_mode)),
1565                write_mask: wgpu::ColorWrites::ALL,
1566            })],
1567        }),
1568        primitive: wgpu::PrimitiveState {
1569            topology: wgpu::PrimitiveTopology::TriangleList,
1570            strip_index_format: None,
1571            front_face: wgpu::FrontFace::Ccw,
1572            cull_mode: None,
1573            unclipped_depth: false,
1574            polygon_mode: wgpu::PolygonMode::Fill,
1575            conservative: false,
1576        },
1577        depth_stencil: None,
1578        multisample: wgpu::MultisampleState::default(),
1579        multiview_mask: None,
1580        cache: None,
1581    })
1582}
1583
1584fn create_glyph_atlas_pipeline(
1585    device: &wgpu::Device,
1586    surface_format: wgpu::TextureFormat,
1587    uniform_layout: &wgpu::BindGroupLayout,
1588    image_layout: &wgpu::BindGroupLayout,
1589) -> wgpu::RenderPipeline {
1590    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1591        label: Some("Glyph Atlas Shader"),
1592        source: wgpu::ShaderSource::Wgsl(shaders::GLYPH_ATLAS_SHADER.into()),
1593    });
1594
1595    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1596        label: Some("Glyph Atlas Pipeline Layout"),
1597        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1598        immediate_size: 0,
1599    });
1600
1601    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1602        label: Some("Glyph Atlas Pipeline"),
1603        layout: Some(&pipeline_layout),
1604        vertex: wgpu::VertexState {
1605            module: &shader,
1606            entry_point: Some("glyph_atlas_vs_main"),
1607            compilation_options: wgpu::PipelineCompilationOptions::default(),
1608            buffers: &[Vertex::desc()],
1609        },
1610        fragment: Some(wgpu::FragmentState {
1611            module: &shader,
1612            entry_point: Some("glyph_atlas_fs_main"),
1613            compilation_options: wgpu::PipelineCompilationOptions::default(),
1614            targets: &[Some(wgpu::ColorTargetState {
1615                format: surface_format,
1616                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1617                write_mask: wgpu::ColorWrites::ALL,
1618            })],
1619        }),
1620        primitive: wgpu::PrimitiveState {
1621            topology: wgpu::PrimitiveTopology::TriangleList,
1622            strip_index_format: None,
1623            front_face: wgpu::FrontFace::Ccw,
1624            cull_mode: None,
1625            unclipped_depth: false,
1626            polygon_mode: wgpu::PolygonMode::Fill,
1627            conservative: false,
1628        },
1629        depth_stencil: None,
1630        multisample: wgpu::MultisampleState::default(),
1631        multiview_mask: None,
1632        cache: None,
1633    })
1634}
1635
1636#[repr(C)]
1637#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1638struct Vertex {
1639    position: [f32; 2],
1640    color: [f32; 4],
1641    uv: [f32; 2],
1642    uv_bounds: [f32; 4],
1643}
1644
1645impl Vertex {
1646    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
1647        0 => Float32x2,
1648        1 => Float32x4,
1649        2 => Float32x2,
1650        3 => Float32x4
1651    ];
1652
1653    fn desc() -> wgpu::VertexBufferLayout<'static> {
1654        wgpu::VertexBufferLayout {
1655            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1656            step_mode: wgpu::VertexStepMode::Vertex,
1657            attributes: &Self::ATTRIBS,
1658        }
1659    }
1660}
1661
1662#[repr(C)]
1663#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1664struct Uniforms {
1665    viewport: [f32; 2],
1666    viewport_offset: [f32; 2],
1667}
1668
1669/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
1670/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
1671/// the uniform address space requires. The quad corners and vertex color ride
1672/// in here because the shape pipeline has no vertex buffer: the vertex shader
1673/// pulls all six corners of a shape straight from this struct.
1674#[repr(C)]
1675#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1676struct ShapeData {
1677    rect: [f32; 4], // x, y, width, height
1678    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
1679    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
1680    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
1681    radii: [f32; 4],
1682    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
1683    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
1684    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
1685    /// arc inner radius. All zero for a plain fill.
1686    stroke_params: [f32; 4],
1687    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
1688    arc_params: [f32; 4],
1689    /// Device-space quad corners 0 (xy) and 1 (zw).
1690    quad01: [f32; 4],
1691    /// Device-space quad corners 2 (xy) and 3 (zw).
1692    quad23: [f32; 4],
1693    /// Vertex color: the solid brush color, or the first gradient stop.
1694    color: [f32; 4],
1695    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
1696    gradient_start: u32,     // Starting index in gradient buffer
1697    gradient_count: u32,     // Number of gradient stops
1698    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
1699}
1700
1701/// Shape kinds understood by `shape.wgsl`.
1702const SHAPE_KIND_FILL: u32 = 0;
1703const SHAPE_KIND_STROKE: u32 = 1;
1704const SHAPE_KIND_ARC: u32 = 2;
1705
1706fn stroke_cap_code(cap: StrokeCap) -> u32 {
1707    match cap {
1708        StrokeCap::Butt => 0,
1709        StrokeCap::Round => 1,
1710        StrokeCap::Square => 2,
1711    }
1712}
1713
1714fn stroke_join_code(join: StrokeJoin) -> u32 {
1715    match join {
1716        StrokeJoin::Miter => 0,
1717        StrokeJoin::Round => 1,
1718        StrokeJoin::Bevel => 2,
1719    }
1720}
1721
1722/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
1723///
1724/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
1725/// which keeps `ShapeData` a slot smaller than it would be if each field got
1726/// its own float — batch capacity is set by this size on uniform backends.
1727fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
1728    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
1729}
1730
1731/// Whether a batch conversion fans out is decided by measurement — see
1732/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
1733/// whose uniform binding caps batches at ~409 shapes never crossed the old
1734/// fixed threshold of 512, so conversion ran serial on exactly the class of
1735/// hardware (watch-grade in-order cores) where fanning out pays most. The
1736/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
1737/// well under that, from ever paying for a spawn wave.
1738#[cfg(not(target_arch = "wasm32"))]
1739static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
1740    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
1741
1742#[cfg(not(target_arch = "wasm32"))]
1743pub(crate) fn shape_convert_worker_count() -> usize {
1744    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1745    *WORKERS.get_or_init(|| {
1746        let cpus = std::thread::available_parallelism()
1747            .map(|count| count.get())
1748            .unwrap_or(1);
1749        let workers = cpus.clamp(1, 4);
1750        // One line per process: on devices whose scheduler confines the
1751        // process (affinity masks, cpusets), this is the number that
1752        // explains why fan-out stages stayed serial.
1753        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
1754        workers
1755    })
1756}
1757
1758#[cfg(target_arch = "wasm32")]
1759pub(crate) fn shape_convert_worker_count() -> usize {
1760    1
1761}
1762
1763fn shape_gradient_stop_count(shape: &DrawShape, brushes: &[Brush]) -> usize {
1764    match shape.brush {
1765        SceneBrush::Solid(_) => 0,
1766        SceneBrush::Gradient(index) => match &brushes[index as usize] {
1767            Brush::Solid(_) => 0,
1768            Brush::LinearGradient { colors, .. }
1769            | Brush::RadialGradient { colors, .. }
1770            | Brush::SweepGradient { colors, .. } => colors.len(),
1771        },
1772    }
1773}
1774
1775/// Converts one [`DrawShape`] into its GPU representation, writing into
1776/// pre-sized slots so a batch can convert in parallel across disjoint
1777/// sub-slices. `gradient_start` is the shape's global offset into the batch
1778/// gradient buffer; `gradient_out` is exactly its span of that buffer.
1779fn convert_shape_into_slots(
1780    shape: &DrawShape,
1781    brushes: &[Brush],
1782    root_scale: f32,
1783    gradient_start: u32,
1784    shape_out: &mut ShapeData,
1785    gradient_out: &mut [GradientStop],
1786) {
1787    let snap_delta = shape
1788        .snap_anchor
1789        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
1790        .unwrap_or_default();
1791    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
1792    let quad = translate_quad(shape.quad, snap_delta);
1793    // Clips are resolved in scene space from their own layer ancestry. A draw
1794    // item's raster snap must never move a fixed ancestor clip.
1795    let clip = shape.clip;
1796    let canonicalize = shape.snap_anchor.is_some();
1797    let device_local_rect = if canonicalize {
1798        canonicalized_scaled_rect(local_rect, root_scale)
1799    } else {
1800        Rect {
1801            x: local_rect.x * root_scale,
1802            y: local_rect.y * root_scale,
1803            width: local_rect.width * root_scale,
1804            height: local_rect.height * root_scale,
1805        }
1806    };
1807    let device_quad = if canonicalize {
1808        canonicalized_scaled_quad(quad, root_scale)
1809    } else {
1810        scaled_quad(quad, root_scale)
1811    };
1812    let canonicalize_brush_coordinate = |value| {
1813        if canonicalize {
1814            canonicalize_device_coordinate(value)
1815        } else {
1816            value
1817        }
1818    };
1819
1820    // Clip rect (scaled to physical pixels)
1821    let clip_rect = if let Some(clip) = clip {
1822        let device_clip = if canonicalize {
1823            canonicalized_scaled_rect(clip, root_scale)
1824        } else {
1825            Rect {
1826                x: clip.x * root_scale,
1827                y: clip.y * root_scale,
1828                width: clip.width * root_scale,
1829                height: clip.height * root_scale,
1830            }
1831        };
1832        [
1833            device_clip.x,
1834            device_clip.y,
1835            device_clip.width,
1836            device_clip.height,
1837        ]
1838    } else {
1839        [0.0, 0.0, 0.0, 0.0]
1840    };
1841
1842    // Gradient parameters
1843    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
1844        let count = colors.len();
1845        let explicit_stops = stops.filter(|values| values.len() == count);
1846        for (index, color) in colors.iter().enumerate() {
1847            let position = explicit_stops
1848                .map(|values| values[index])
1849                .unwrap_or_else(|| {
1850                    if count <= 1 {
1851                        0.0
1852                    } else {
1853                        index as f32 / (count - 1) as f32
1854                    }
1855                });
1856            gradient_out[index] = GradientStop {
1857                color: [color.r(), color.g(), color.b(), color.a()],
1858                position: [position, 0.0, 0.0, 0.0],
1859            };
1860        }
1861        count as u32
1862    };
1863    let mut gradient_params = [0.0f32; 4];
1864    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
1865        SceneBrush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
1866        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
1867            Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
1868            Brush::LinearGradient {
1869                colors,
1870                stops,
1871                start,
1872                end,
1873                tile_mode,
1874            } => {
1875                let count = fill_gradient_entries(colors, stops.as_deref());
1876                gradient_params = [
1877                    canonicalize_brush_coordinate(resolve_gradient_point(
1878                        device_local_rect.x,
1879                        device_local_rect.width,
1880                        start.x * root_scale,
1881                    )),
1882                    canonicalize_brush_coordinate(resolve_gradient_point(
1883                        device_local_rect.y,
1884                        device_local_rect.height,
1885                        start.y * root_scale,
1886                    )),
1887                    canonicalize_brush_coordinate(resolve_gradient_point(
1888                        device_local_rect.x,
1889                        device_local_rect.width,
1890                        end.x * root_scale,
1891                    )),
1892                    canonicalize_brush_coordinate(resolve_gradient_point(
1893                        device_local_rect.y,
1894                        device_local_rect.height,
1895                        end.y * root_scale,
1896                    )),
1897                ];
1898                (1u32, count, gradient_tile_mode_value(*tile_mode))
1899            }
1900            Brush::RadialGradient {
1901                colors,
1902                stops,
1903                center,
1904                radius,
1905                tile_mode,
1906            } => {
1907                let count = fill_gradient_entries(colors, stops.as_deref());
1908                gradient_params = [
1909                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1910                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1911                    (radius * root_scale).max(f32::EPSILON),
1912                    0.0,
1913                ];
1914                (2u32, count, gradient_tile_mode_value(*tile_mode))
1915            }
1916            Brush::SweepGradient {
1917                colors,
1918                stops,
1919                center,
1920            } => {
1921                let count = fill_gradient_entries(colors, stops.as_deref());
1922                gradient_params = [
1923                    canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1924                    canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1925                    0.0,
1926                    0.0,
1927                ];
1928                (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
1929            }
1930        },
1931    };
1932
1933    // A stroked rect/round-rect was emitted with `local_rect` already
1934    // inflated by half the stroke width, so corner radii must resolve
1935    // against the geometry that was actually asked for, not the
1936    // inflated box. The shader shrinks `half_size` by the same amount.
1937    let stroke_outset = shape
1938        .stroke
1939        .map(|stroke| stroke.half_width())
1940        .unwrap_or(0.0);
1941    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
1942    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
1943
1944    let radii = if let Some(arc) = shape.arc {
1945        // Arcs never carry corner radii, so this slot ships the shader's
1946        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
1947        // the half sweep. Computing these here — once per shape — is what
1948        // lets `sdf_arc_band` run without a single transcendental per
1949        // fragment. A full ring is the common case (dots, particles) and
1950        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
1951        // values are exact constants; the half-sweep sine is pinned to
1952        // non-negative just like the shader used to, so a closed ring keeps
1953        // its seam-free (0, -1) form.
1954        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
1955            [0.0, -1.0, 0.0, -1.0]
1956        } else {
1957            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
1958            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
1959            let (half_sin, half_cos) = half_sweep.sin_cos();
1960            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
1961        }
1962    } else if let Some(rounded) = shape.shape {
1963        let resolved = rounded.resolve(geometry_width, geometry_height);
1964        [
1965            resolved.top_left * root_scale,
1966            resolved.top_right * root_scale,
1967            resolved.bottom_left * root_scale,
1968            resolved.bottom_right * root_scale,
1969        ]
1970    } else {
1971        [0.0, 0.0, 0.0, 0.0]
1972    };
1973
1974    let device_rect = [
1975        device_local_rect.x,
1976        device_local_rect.y,
1977        device_local_rect.width,
1978        device_local_rect.height,
1979    ];
1980
1981    // Stroke/arc parameters ride in the same ShapeData and the same
1982    // pipeline as fills, so a stroked or arc shape never splits a
1983    // batch.
1984    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
1985        (Some(arc), _) => (
1986            [
1987                0.0,
1988                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
1989                arc.outer_radius * root_scale,
1990                arc.inner_radius * root_scale,
1991            ],
1992            [
1993                (arc.center.x + snap_delta.x) * root_scale,
1994                (arc.center.y + snap_delta.y) * root_scale,
1995                arc.start_angle,
1996                arc.sweep_angle,
1997            ],
1998        ),
1999        (None, Some(stroke)) => (
2000            [
2001                stroke.width.max(0.0) * root_scale,
2002                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
2003                0.0,
2004                0.0,
2005            ],
2006            [0.0; 4],
2007        ),
2008        (None, None) => (
2009            [
2010                0.0,
2011                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
2012                0.0,
2013                0.0,
2014            ],
2015            [0.0; 4],
2016        ),
2017    };
2018
2019    let color = match &shape.brush {
2020        SceneBrush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2021        SceneBrush::Gradient(index) => match &brushes[*index as usize] {
2022            Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2023            Brush::LinearGradient { colors, .. } => {
2024                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2025                [first.r(), first.g(), first.b(), first.a()]
2026            }
2027            Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2028                let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2029                [first.r(), first.g(), first.b(), first.a()]
2030            }
2031        },
2032    };
2033
2034    *shape_out = ShapeData {
2035        rect: device_rect,
2036        radii,
2037        gradient_params,
2038        clip_rect,
2039        stroke_params,
2040        arc_params,
2041        quad01: [
2042            device_quad[0][0],
2043            device_quad[0][1],
2044            device_quad[1][0],
2045            device_quad[1][1],
2046        ],
2047        quad23: [
2048            device_quad[2][0],
2049            device_quad[2][1],
2050            device_quad[3][0],
2051            device_quad[3][1],
2052        ],
2053        color,
2054        brush_type,
2055        gradient_start,
2056        gradient_count,
2057        gradient_tile_mode,
2058    };
2059}
2060
2061/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2062/// pixels the emitted quads cover — split into arc quads, the true arc band
2063/// coverage inside them, and everything else. Fill cost is the product of
2064/// fragment count and shader cost, and this is the fragment-count half: it
2065/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2066/// the SDF discards) was measured.
2067fn quad_area_diag_enabled() -> bool {
2068    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2069    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2070}
2071
2072/// Converts a batch of shapes into pre-sized output slices, fanning the work
2073/// across scoped threads when the batch is large enough to pay for spawns.
2074/// The outputs may be scratch vectors or mapped GPU staging memory; each
2075/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2076/// hand-off keeps the parallel path free of any synchronization.
2077fn convert_shapes_into_outputs(
2078    shape_refs: &[&DrawShape],
2079    brushes: &[Brush],
2080    gradient_offsets: &[u32],
2081    root_scale: f32,
2082    shape_data_out: &mut [ShapeData],
2083    gradients_out: &mut [GradientStop],
2084) {
2085    let shape_count = shape_refs.len();
2086    #[cfg(not(target_arch = "wasm32"))]
2087    let convert_started = Instant::now();
2088    #[cfg(not(target_arch = "wasm32"))]
2089    let parallel =
2090        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2091    if quad_area_diag_enabled() {
2092        let quad_area = |q: [[f32; 2]; 4]| {
2093            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2094            let poly = [q[0], q[1], q[3], q[2]];
2095            let mut twice = 0.0f64;
2096            for i in 0..4 {
2097                let a = poly[i];
2098                let b = poly[(i + 1) % 4];
2099                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2100            }
2101            twice.abs() * 0.5
2102        };
2103        let mut arc_quad = 0.0f64; // quad px of arc shapes
2104        let mut arc_band = 0.0f64; // true band coverage of those arcs
2105        let mut arc_count = 0usize;
2106        let mut ring_count = 0usize;
2107        let mut other_quad = 0.0f64;
2108        let mut other_count = 0usize;
2109        // Largest non-arc quads: (area, index) so the tail of the diag can
2110        // name what the aggregate "other" fill actually is.
2111        let mut top_other: Vec<(f64, usize)> = Vec::new();
2112        for (index, shape) in shape_refs.iter().enumerate() {
2113            let area = quad_area(shape.quad);
2114            if let Some(arc) = shape.arc {
2115                arc_quad += area;
2116                arc_count += 1;
2117                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2118                    ring_count += 1;
2119                }
2120                let ra = arc.mid_radius() as f64;
2121                let rb = arc.half_thickness() as f64;
2122                arc_band +=
2123                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2124            } else {
2125                other_quad += area;
2126                other_count += 1;
2127                top_other.push((area, index));
2128            }
2129        }
2130        let scale2 = (root_scale as f64) * (root_scale as f64);
2131        eprintln!(
2132            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2133            arc_quad * scale2,
2134            arc_band * scale2,
2135            other_quad * scale2,
2136        );
2137        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2138        for &(area, index) in top_other.iter().take(4) {
2139            let shape = shape_refs[index];
2140            let brush = match shape.brush.resolve(brushes).as_ref() {
2141                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2142                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2143                    format!("linear n={}", colors.len())
2144                }
2145                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2146                    format!("radial n={}", colors.len())
2147                }
2148                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2149                    format!("sweep n={}", colors.len())
2150                }
2151            };
2152            eprintln!(
2153                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2154                area * scale2,
2155                shape.rect.width.round(),
2156                shape.rect.height.round(),
2157                shape.rect.x,
2158                shape.rect.y,
2159                brush,
2160                shape.shape.is_some(),
2161                shape.stroke.is_some(),
2162                shape.clip.is_some(),
2163                shape.blend_mode,
2164                shape.z_index,
2165            );
2166        }
2167    }
2168    #[cfg(target_arch = "wasm32")]
2169    let parallel = false;
2170    let workers = if parallel {
2171        shape_convert_worker_count()
2172    } else {
2173        1
2174    };
2175    if workers <= 1 {
2176        for (idx, shape) in shape_refs.iter().enumerate() {
2177            let gradient_start = gradient_offsets[idx];
2178            let gradient_end = gradient_offsets[idx + 1];
2179            convert_shape_into_slots(
2180                shape,
2181                brushes,
2182                root_scale,
2183                gradient_start,
2184                &mut shape_data_out[idx],
2185                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2186            );
2187        }
2188        #[cfg(not(target_arch = "wasm32"))]
2189        SHAPE_CONVERT_TUNER.record(
2190            false,
2191            shape_count,
2192            convert_started.elapsed().as_nanos() as u64,
2193        );
2194        return;
2195    }
2196
2197    let chunk_len = shape_count.div_ceil(workers);
2198    let mut shape_data_rest = shape_data_out;
2199    let mut gradients_rest = gradients_out;
2200    std::thread::scope(|scope| {
2201        let mut chunk_start = 0usize;
2202        while chunk_start < shape_count {
2203            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2204            let count = chunk_end - chunk_start;
2205            let gradient_base = gradient_offsets[chunk_start];
2206            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2207            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2208            shape_data_rest = rest;
2209            let (gradient_chunk, rest) =
2210                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2211            gradients_rest = rest;
2212            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2213            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2214            let mut convert_chunk = move || {
2215                for (j, shape) in chunk_refs.iter().enumerate() {
2216                    let gradient_start = chunk_offsets[j];
2217                    let local_start = (gradient_start - gradient_base) as usize;
2218                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2219                    convert_shape_into_slots(
2220                        shape,
2221                        brushes,
2222                        root_scale,
2223                        gradient_start,
2224                        &mut shape_data_chunk[j],
2225                        &mut gradient_chunk[local_start..local_end],
2226                    );
2227                }
2228            };
2229            if chunk_end == shape_count {
2230                // The caller would only block at the scope join; converting
2231                // the final chunk inline puts that time to work and saves a
2232                // spawn.
2233                convert_chunk();
2234            } else {
2235                scope.spawn(convert_chunk);
2236            }
2237            chunk_start = chunk_end;
2238        }
2239    });
2240    #[cfg(not(target_arch = "wasm32"))]
2241    SHAPE_CONVERT_TUNER.record(
2242        true,
2243        shape_count,
2244        convert_started.elapsed().as_nanos() as u64,
2245    );
2246}
2247
2248#[repr(C)]
2249#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2250struct GradientStop {
2251    color: [f32; 4],
2252    position: [f32; 4],
2253}
2254
2255/// How many replay slots the shared transform buffer holds. Each slot's
2256/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2257/// strictest uniform-offset requirement any backend reports.
2258#[cfg(not(target_arch = "wasm32"))]
2259const MAX_REPLAY_SLOTS: u32 = 128;
2260#[cfg(not(target_arch = "wasm32"))]
2261const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2262
2263/// One retained replay batch: converted shape slots captured on an earlier
2264/// frame, kept on the GPU and re-drawn each frame under the similarity
2265/// transform staged at `transform_offset`.
2266///
2267/// The immutable `ShapeData` and gradient buffers hold no handle here:
2268/// nothing addresses them after capture, and `bind_group` keeps them alive.
2269#[cfg(not(target_arch = "wasm32"))]
2270struct ReplaySlot {
2271    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2272    /// under `paint_select`, split out so recolor patches upload 16 bytes
2273    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2274    /// from capture to release.
2275    paint_buffer: wgpu::Buffer,
2276    bind_group: wgpu::BindGroup,
2277    shape_count: u32,
2278    /// CPU mirror of the paint buffer. Recolor patches apply here first
2279    /// and upload as one contiguous span per slot per frame — MEGA's
2280    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2281    /// copy commands stall a mobile GPU for longer than the spans' extra
2282    /// bytes ever could.
2283    paint_mirror: Vec<[f32; 4]>,
2284    /// Conservative capture-space arc/ring mesh, built once at capture.
2285    /// `None` when the kill switch is off, the slot meshed no arcs, or the
2286    /// vertex budget overflowed — those slots replay through the quad-expansion
2287    /// six-vertices-per-shape path.
2288    mesh: Option<ReplaySlotMesh>,
2289    /// Which capture created this slot's buffers, from the store's global
2290    /// monotone counter. Retained bundle keys carry it so a slot id that is
2291    /// released and recaptured — new bind group, new buffers, same id — can
2292    /// never be drawn through a bundle recorded against the old capture.
2293    capture_epoch: u64,
2294    /// Whether any captured shape carries gradient stops. False routes the
2295    /// slot's quad-expansion draws through the `fs_solid` pipelines; fixed
2296    /// for the life of the capture, so bundle keys need nothing beyond the
2297    /// capture epoch they already carry.
2298    has_gradient: bool,
2299    /// Per-shape capture-space fill records for the `CRANPOSE_FILL_DIAG`
2300    /// instrument (`shape_count` entries): submitted area (mesh triangles
2301    /// when this slot replays its arc mesh, bounding quads otherwise),
2302    /// analytic lit area, opacity class and quad AABB. Empty when the
2303    /// diagnostic is off.
2304    fill_diag_shapes: Vec<FillDiagShapeRecord>,
2305}
2306
2307/// Vertex geometry a retained slot replays instead of per-shape quads: arc
2308/// bands get trapezoid strips covering only their antialiasing footprint,
2309/// every other shape gets a passthrough pair of triangles identical to the
2310/// quad expansion. See [`build_arc_mesh_vertices`].
2311#[cfg(not(target_arch = "wasm32"))]
2312struct ReplaySlotMesh {
2313    vertex_buffer: wgpu::Buffer,
2314    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2315    /// vertices are emitted once and shared by both adjacent trapezoids, so
2316    /// per-arc vertex-shader work drops from ~30 executions to the unique
2317    /// boundary vertices (~10-14) — the amplification that made the
2318    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2319    index_buffer: wgpu::Buffer,
2320    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2321    /// indices `index_prefix[i]..index_prefix[i + 1]`, so a retained span
2322    /// draws `index_prefix[first]..index_prefix[first + count]` — one
2323    /// `draw_indexed` per op, identical shape order, z untouched.
2324    index_prefix: Vec<u32>,
2325}
2326
2327/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2328/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2329/// and the shape index standing in for `vertex_index / 6`.
2330#[cfg(not(target_arch = "wasm32"))]
2331#[repr(C)]
2332#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2333struct MeshVertex {
2334    position: [f32; 2],
2335    uv: [f32; 2],
2336    shape_idx: u32,
2337}
2338
2339#[cfg(not(target_arch = "wasm32"))]
2340impl MeshVertex {
2341    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2342        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2343
2344    fn desc() -> wgpu::VertexBufferLayout<'static> {
2345        wgpu::VertexBufferLayout {
2346            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2347            step_mode: wgpu::VertexStepMode::Vertex,
2348            attributes: &Self::ATTRIBS,
2349        }
2350    }
2351}
2352
2353/// Kill switch, mirroring `command_feed_enabled`: default ON,
2354/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2355/// Android) makes the next capture skip mesh building entirely, so a device
2356/// A/B needs no rebuild. Read per capture — captures are rare.
2357#[cfg(not(target_arch = "wasm32"))]
2358fn arc_mesh_enabled() -> bool {
2359    // Opt-in (CRANPOSE_ARC_MESH=1 / debug.cranpose.arc_mesh): the Gate 0
2360    // off-charger watch A/B measured the non-indexed mesh 5-7 fps SLOWER
2361    // than plain quads on the Adreno 702 — the 4-6x vertex amplification
2362    // outweighs the fragment savings on a small binning GPU (big desktop
2363    // GPUs and the at-vsync-ceiling Huawei masked it). Default returns to
2364    // quad expansion until indexed band-boundary geometry removes the
2365    // amplification; then the A/B is repeated.
2366    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok(v) if v != "0")
2367}
2368
2369/// Dilation applied to the band's half-thickness before meshing, in capture
2370/// device pixels. The fragment SDF feathers over ±0.5 px
2371/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2372/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2373/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2374#[cfg(not(target_arch = "wasm32"))]
2375const ARC_MESH_MARGIN: f32 = 1.0;
2376
2377/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2378/// outer edges tangent-outside the dilated outer circle overshoots it by
2379/// about this much at the chord ends.
2380#[cfg(not(target_arch = "wasm32"))]
2381const ARC_MESH_OVERSHOOT: f32 = 2.0;
2382
2383#[cfg(not(target_arch = "wasm32"))]
2384const ARC_MESH_MIN_SEGMENTS: usize = 4;
2385#[cfg(not(target_arch = "wasm32"))]
2386const ARC_MESH_MAX_SEGMENTS: usize = 64;
2387
2388/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2389/// shape, floored for tiny slots so a single huge ring still fits. The
2390/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2391/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2392/// which indexed geometry fits with more headroom (MEGA's retained arcs
2393/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2394/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2395/// truncating silently would break the containment invariant.
2396#[cfg(not(target_arch = "wasm32"))]
2397const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2398#[cfg(not(target_arch = "wasm32"))]
2399const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2400
2401/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2402/// actually hold.
2403#[cfg(not(target_arch = "wasm32"))]
2404fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2405    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2406}
2407
2408/// Band parameters of a captured arc that qualifies for a conservative mesh:
2409/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2410/// axis-aligned box of its rect. Everything else returns `None` and passes
2411/// through as today's two quad triangles.
2412#[cfg(not(target_arch = "wasm32"))]
2413struct ArcMeshBand {
2414    center: [f32; 2],
2415    inner: f32,
2416    outer: f32,
2417    start: f32,
2418    sweep: f32,
2419}
2420
2421#[cfg(not(target_arch = "wasm32"))]
2422fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2423    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2424    let flags = shape.stroke_params[1].max(0.0) as u32;
2425    if flags & 3 != SHAPE_KIND_ARC {
2426        return None;
2427    }
2428    // Solid brushes only: gradients also derive from `rect_pos` and would
2429    // mesh in principle, but the hot retained scenes are solid and a narrow
2430    // gate keeps the byte-exactness surface small.
2431    if shape.brush_type != 0 {
2432        return None;
2433    }
2434    // A live clip is a hard `world_pos` comparison in the fragment shader.
2435    // Meshed arcs interpolate `world_pos` across different triangles than
2436    // the quad would, and one ulp of difference at the clip boundary flips
2437    // whole pixels — clipped arcs pass through untouched.
2438    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2439        return None;
2440    }
2441    let [_, _, w, h] = shape.rect;
2442    if !(w > 0.0 && h > 0.0) {
2443        return None;
2444    }
2445    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2446    // clipped to the quad's own corners, so as long as the quad IS a box its
2447    // rasterized pixel set equals the mesh clip region and the tight-AABB
2448    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2449    // instead is an over-tight gate: under a non-dyadic root scale
2450    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2451    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2452    let [left, top, right, _] = shape.quad01;
2453    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2454    let axis_aligned = shape.quad01[3] == top
2455        && bl_x == left
2456        && br_x == right
2457        && br_y == bottom
2458        && left < right
2459        && top < bottom;
2460    if !axis_aligned {
2461        return None;
2462    }
2463    let center = [shape.arc_params[0], shape.arc_params[1]];
2464    let start = shape.arc_params[2];
2465    let sweep = shape.arc_params[3];
2466    let outer = shape.stroke_params[2];
2467    let inner = shape.stroke_params[3];
2468    let finite = center[0].is_finite()
2469        && center[1].is_finite()
2470        && start.is_finite()
2471        && sweep.is_finite()
2472        && outer.is_finite()
2473        && inner.is_finite();
2474    if !finite || outer <= 0.0 || sweep <= 0.0 {
2475        return None;
2476    }
2477    Some(ArcMeshBand {
2478        center,
2479        inner,
2480        outer,
2481        start,
2482        sweep,
2483    })
2484}
2485
2486/// Kill switch for the transient rim band mesh, mirroring
2487/// [`arc_mesh_enabled`]'s property bridge: `CRANPOSE_RIM_MESH=0` (or the
2488/// `debug.cranpose.rim_mesh` property on Android) makes the fused shape
2489/// prepare skip rim detection entirely, so a device A/B needs no rebuild.
2490/// Default ON — unlike the retained arc mesh, the rim path is indexed
2491/// band-boundary geometry from the start, so the vertex-amplification
2492/// regression that demoted `CRANPOSE_ARC_MESH` to opt-in does not apply.
2493/// Read once per fused-chunk prepare (cheap), not per shape.
2494#[cfg(not(target_arch = "wasm32"))]
2495fn rim_mesh_enabled() -> bool {
2496    !matches!(std::env::var("CRANPOSE_RIM_MESH").as_deref(), Ok("0"))
2497}
2498
2499/// Fixed capacity of the per-frame transient rim mesh vertex buffer, in
2500/// vertices. The buffers are never recreated mid-frame — draws are encoded
2501/// before submit, so a reallocation would orphan already-encoded rims — and
2502/// overflow means "skip the rim, draw it as a quad", never truncation.
2503/// MEGA's arena meshes 2-3 rims per frame at ~80 vertices each (measured on
2504/// the Pixel Watch 3 via the emit log below), so ~100 rims of headroom; the
2505/// rate-limited warn below is the tell if a scene ever exceeds it.
2506#[cfg(not(target_arch = "wasm32"))]
2507const RIM_MESH_VERTEX_CAPACITY: usize = 8192;
2508/// Fixed capacity of the per-frame transient rim mesh index buffer, in
2509/// `u32` indices.
2510#[cfg(not(target_arch = "wasm32"))]
2511const RIM_MESH_INDEX_CAPACITY: usize = 32768;
2512
2513/// A dynamic shape inside a fused chunk that draws as a band mesh instead of
2514/// its full bounding quad: `shape_index` is the shape's position within the
2515/// whole fused upload (the index `vs_mesh` reads into the storage shape
2516/// array), `first_index..first_index + index_count` its span of the frame's
2517/// transient rim index buffer.
2518#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
2519#[derive(Clone, Copy, Debug)]
2520struct RimDraw {
2521    shape_index: u32,
2522    first_index: u32,
2523    index_count: u32,
2524}
2525
2526/// Rate-limited overflow warning: silent skipping would hide a scene whose
2527/// rims permanently miss the fast path, while warning every frame would
2528/// flood the watch's logcat.
2529#[cfg(not(target_arch = "wasm32"))]
2530fn rim_mesh_capacity_warn() {
2531    use std::sync::atomic::{AtomicU64, Ordering};
2532    static OVERFLOWS: AtomicU64 = AtomicU64::new(0);
2533    let count = OVERFLOWS.fetch_add(1, Ordering::Relaxed);
2534    if count.is_multiple_of(512) {
2535        log::warn!(
2536            "[rim-mesh] transient buffers full; rim falls back to quad expansion \
2537             (lifetime overflows {})",
2538            count + 1,
2539        );
2540    }
2541}
2542
2543/// Band parameters of a DYNAMIC stroked round-rect whose outline is
2544/// geometrically a circle — an arena "rim". Everything else returns `None`
2545/// and rasterizes through the ordinary quad expansion.
2546///
2547/// Derivation: `ShapeData::rect` for a stroked shape is the stroke-inflated
2548/// box (geometry plus half the stroke width on each side), so the geometry
2549/// half-extent is `geom_half = (rect.w - stroke_width) / 2`. When the corner
2550/// radius equals that half-extent the outline is a circle of radius
2551/// `geom_half`, and `sdf_stroked_rounded_rect` degenerates exactly to an
2552/// annulus: its outer offset rounded-rect (`half_size` = `geom_half + hw`,
2553/// radius `geom_half + hw`) is the circle of radius `geom_half + sw/2`, its
2554/// inner offset the circle of radius `geom_half - sw/2` — centerline
2555/// `geom_half`, half-width `sw/2`. The bevel-join chamfer plane can only CUT
2556/// pixels from that annulus (`max(dist, chamfer)`), never add any, so for
2557/// every join style the shader's kept set is a subset of the annulus band.
2558/// [`emit_arc_band_mesh`] adds its own `ARC_MESH_MARGIN`, treats
2559/// `sweep >= TAU` as closed, and clips to the quad box, so containment
2560/// (mesh ⊇ every pixel with `|dist| < 0.5`, mesh ⊆ quad box) follows from
2561/// the same argument the retained arc mesh documents.
2562///
2563/// The CIRCLE gate is what keeps this correct: a false positive on a rounded
2564/// SQUARE ring would under-cover its flat spans and damage pixels, so the
2565/// radius must match `geom_half` to within 0.01 px (a deviation that small
2566/// stays inside the mesh margin's 0.5 px float-slop budget).
2567#[cfg(not(target_arch = "wasm32"))]
2568fn rim_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2569    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2570    let flags = shape.stroke_params[1].max(0.0) as u32;
2571    if flags & 3 != SHAPE_KIND_STROKE {
2572        return None;
2573    }
2574    // Solid brushes only — same narrow byte-exactness surface as
2575    // `arc_mesh_band`.
2576    if shape.brush_type != 0 {
2577        return None;
2578    }
2579    // A live clip is a hard `world_pos` comparison in the fragment shader;
2580    // meshed rims interpolate `world_pos` across different triangles and one
2581    // ulp at the clip boundary flips whole pixels.
2582    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2583        return None;
2584    }
2585    let [x, y, w, h] = shape.rect;
2586    if !(w > 0.0 && h > 0.0) {
2587        return None;
2588    }
2589    // The quad must be an axis-aligned box, tolerance zero — the identical
2590    // check `arc_mesh_band` makes (compare quad corners against each other,
2591    // never against `rect`, which differs by an ulp under non-dyadic root
2592    // scales).
2593    let [left, top, right, _] = shape.quad01;
2594    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2595    let axis_aligned = shape.quad01[3] == top
2596        && bl_x == left
2597        && br_x == right
2598        && br_y == bottom
2599        && left < right
2600        && top < bottom;
2601    if !axis_aligned {
2602        return None;
2603    }
2604    // Big shapes only: the win is proportional to the discarded quad area,
2605    // and small quads are cheaper than the extra pipeline switches.
2606    if w * h < 65536.0 {
2607        return None;
2608    }
2609    // A circle's box is square, bitwise.
2610    if w.to_bits() != h.to_bits() {
2611        return None;
2612    }
2613    // All four corner radii bitwise equal, finite and positive.
2614    let [r0, r1, r2, r3] = shape.radii;
2615    if r0.to_bits() != r1.to_bits() || r0.to_bits() != r2.to_bits() || r0.to_bits() != r3.to_bits()
2616    {
2617        return None;
2618    }
2619    if !r0.is_finite() || r0 <= 0.0 {
2620        return None;
2621    }
2622    let sw = shape.stroke_params[0];
2623    if !sw.is_finite() || sw <= 0.0 {
2624        return None;
2625    }
2626    // Finiteness before the circle gate: with every operand finite the
2627    // radius comparison below cannot see a NaN.
2628    let geom_half = (w - sw) * 0.5;
2629    let center = [x + w * 0.5, y + h * 0.5];
2630    let inner = geom_half - sw * 0.5;
2631    let outer = geom_half + sw * 0.5;
2632    let finite =
2633        center[0].is_finite() && center[1].is_finite() && inner.is_finite() && outer.is_finite();
2634    if !finite || outer <= 0.0 {
2635        return None;
2636    }
2637    // The circle gate (see the doc comment).
2638    if (r0 - geom_half).abs() > 0.01 {
2639        return None;
2640    }
2641    Some(ArcMeshBand {
2642        center,
2643        inner,
2644        outer,
2645        start: 0.0,
2646        sweep: cranpose_ui_graphics::TAU,
2647    })
2648}
2649
2650/// Kill switch for the opaque static leading-span cache, mirroring
2651/// [`rim_mesh_enabled`]'s property bridge: `CRANPOSE_STATIC_SPAN=0` (or the
2652/// `debug.cranpose.static_span` property on Android) makes the fused
2653/// partition never skip, capture, or blit — a device A/B needs no rebuild.
2654/// Default ON. Read once per engagement attempt (once per frame), so the
2655/// cost is one `env::var` per frame.
2656#[cfg(not(target_arch = "wasm32"))]
2657fn static_span_enabled() -> bool {
2658    !matches!(std::env::var("CRANPOSE_STATIC_SPAN").as_deref(), Ok("0"))
2659}
2660
2661/// Upper bound on how many leading shapes one span may cover. The target
2662/// span (full-screen background rect + vignette disc) is 2 shapes; the cap
2663/// only bounds the per-frame memcmp (16 x 160 B) and the prev-frame copy.
2664#[cfg(not(target_arch = "wasm32"))]
2665const STATIC_SPAN_MAX_SHAPES: usize = 16;
2666
2667/// Consecutive stable frames an EXTENSION of an already-valid span must
2668/// show before an upgrade recapture — see the hysteresis comment in
2669/// [`StaticSpanCache::engage`].
2670#[cfg(not(target_arch = "wasm32"))]
2671const STATIC_SPAN_UPGRADE_FRAMES: u32 = 30;
2672
2673/// What the engagement check decided for this frame's leading fused
2674/// partition.
2675#[cfg(not(target_arch = "wasm32"))]
2676#[derive(Clone, Copy, Debug, PartialEq)]
2677enum StaticSpanDecision {
2678    /// Not engaged: draw everything live, capture nothing.
2679    Pass,
2680    /// The cached span image is valid: skip the first `skip` shapes of the
2681    /// first batch and draw the cached full-target blit before everything.
2682    Hit { skip: usize },
2683    /// The leading `len` shapes were byte-stable across the last two frames
2684    /// but the cache does not match: draw live, then re-capture the span.
2685    Capture { len: usize, clear: wgpu::Color },
2686}
2687
2688/// Cache of the frame's leading static span — the opaque full-screen
2689/// background rect plus whatever byte-stable draws sit directly on top of it
2690/// (MEGA: the ~176k-px radial-gradient vignette disc) — as one composited
2691/// full-target texture that replaces those draws with a single blit.
2692///
2693/// Byte-exactness by construction, no tolerance anywhere:
2694///
2695/// * The engaged partition is the frame's first content (`load_op` is the
2696///   frame `Clear`, gated to alpha == 1.0), so what the live path would put
2697///   under the span is exactly the opaque clear color — and the capture
2698///   pass clears its offscreen with the SAME color before drawing the SAME
2699///   shape range through the IDENTICAL pipelines (same `ShapeData` bytes,
2700///   same gradient stop bytes, same viewport uniforms, same blend state,
2701///   same `has_gradient` pipeline variant, same surface format, identity
2702///   similarity offset 0). Deterministic pipelines on identical inputs give
2703///   identical bytes, so the cached image IS the bytes the live span render
2704///   would produce this frame.
2705/// * With an opaque clear below and SrcOver-only draws above, every texel of
2706///   that composite has alpha exactly 255: each blend step computes
2707///   `a_out = a_src + (1 - a_src) * 1.0`, whose float error is far inside
2708///   the half-level the unorm8 quantizer absorbs, and 255 reads back as
2709///   exactly 1.0 for the next step. The replacement blit then draws SrcOver
2710///   texels whose `1 - src.a` dst factor is exactly zero — the
2711///   fixed-function blender computes `1*src + 0*dst`, a replace-write — and
2712///   an unorm8 texel survives the sample/write round trip bit-exact
2713///   (`CompositeSampleMode::Nearest` is a `textureLoad`, `alpha` is 1.0).
2714///   Hence `over(rest, over(span, clear)) == over(rest, SPAN_IMAGE)`
2715///   bitwise, whatever `rest` is.
2716/// * Gradient dither cannot diverge between capture and screen: `shape.wgsl`
2717///   keys its ordered-dither matrix off `world_pos` — the device coordinate
2718///   interpolated from the `ShapeData` quad corners, deliberately not
2719///   `@builtin(position)` — so the dither phase is a pure function of the
2720///   memcmp'd bytes (see `gradient_dither` in `shape.wgsl`).
2721/// * Rim-mesh candidates ([`rim_mesh_band`] Some) end the span: the live
2722///   path may draw them through the band-mesh pipeline while the capture
2723///   pass draws plain instanced quads, and this cache refuses to depend on
2724///   that pair being byte-equal.
2725///
2726/// Validity is a memcmp: the leading K converted `ShapeData` records plus
2727/// their gradient stop payloads against the cached copy, ~160 B x few
2728/// shapes, sub-microsecond. The span length K itself comes from a two-frame
2729/// stability probe (`prev_shapes`): a capture only happens once the leading
2730/// run has already repeated byte-identically across two consecutive frames,
2731/// so churning scenes never pay the extra capture pass every frame — and
2732/// only when the span carries at least one gradient record, so scenes whose
2733/// leading static draws are all solid (cheap fill the blit cannot beat)
2734/// never engage at all.
2735#[cfg(not(target_arch = "wasm32"))]
2736#[derive(Default)]
2737struct StaticSpanCache {
2738    /// The captured span composite, same size and format as the frame
2739    /// target. Held out of the offscreen pool across frames; released back
2740    /// through the deferred-release path on resize.
2741    texture: Option<OffscreenTarget>,
2742    /// Validity key: the span's converted `ShapeData` records at capture.
2743    key_shapes: Vec<ShapeData>,
2744    /// Validity key: the span's gradient stop payload at capture.
2745    key_gradients: Vec<GradientStop>,
2746    key_width: u32,
2747    key_height: u32,
2748    /// The frame clear color the capture pass cleared with — pixels the
2749    /// span shapes do not fully cover composite against it, so a different
2750    /// clear invalidates the image even when every shape byte matches.
2751    key_clear: [u64; 4],
2752    /// The live first batch's whole-batch `has_gradient` flag at capture:
2753    /// it selects the `fs_solid` vs gradient pipeline variant for every
2754    /// shape in the batch, so the capture is only valid while the live
2755    /// batch would draw the span through the same variant.
2756    key_has_gradient: bool,
2757    /// Last frame's leading records — the two-frame stability probe that
2758    /// decides the span length at capture time.
2759    prev_shapes: Vec<ShapeData>,
2760    prev_gradients: Vec<GradientStop>,
2761    /// Consecutive hit frames whose stable leading run extended past the
2762    /// current key — the upgrade hysteresis counter.
2763    extension_stable_frames: u32,
2764    /// Set once per frame by [`GpuRenderer::render`], consumed by the first
2765    /// fused partition that carries the frame's opaque clear, so offscreen
2766    /// layer or shadow renders (transparent clears) can never engage and a
2767    /// frame engages at most once.
2768    armed: bool,
2769    hits: u64,
2770    recaptures: u64,
2771}
2772
2773#[cfg(not(target_arch = "wasm32"))]
2774impl StaticSpanCache {
2775    /// One engagement attempt per frame, at fused-partition time.
2776    /// `first_batch` is the chunk's first batch when it is a shape batch:
2777    /// (shape count, blend mode, whole-batch has_gradient). `shapes` /
2778    /// `gradients` are the partition's freshly converted scratch buffers,
2779    /// whose leading records belong to the first batch.
2780    fn engage(
2781        &mut self,
2782        load_op: wgpu::LoadOp<wgpu::Color>,
2783        first_batch: Option<(usize, BlendMode, bool)>,
2784        width: u32,
2785        height: u32,
2786        shapes: &[ShapeData],
2787        gradients: &[GradientStop],
2788    ) -> StaticSpanDecision {
2789        if !self.armed || !static_span_enabled() {
2790            return StaticSpanDecision::Pass;
2791        }
2792        let wgpu::LoadOp::Clear(clear) = load_op else {
2793            return StaticSpanDecision::Pass;
2794        };
2795        // The frame's leading clear is the only opaque one a frame stream
2796        // carries (layer and shadow sources clear transparent); engagement
2797        // happens here or not at all this frame.
2798        if clear.a != 1.0 {
2799            return StaticSpanDecision::Pass;
2800        }
2801        self.armed = false;
2802        let Some((batch_len, blend_mode, has_gradient)) = first_batch else {
2803            self.forget_observation();
2804            return StaticSpanDecision::Pass;
2805        };
2806        // SrcOver only: the alpha == 255 argument above is an SrcOver
2807        // property.
2808        if blend_mode != BlendMode::SrcOver || batch_len == 0 {
2809            self.forget_observation();
2810            return StaticSpanDecision::Pass;
2811        }
2812        let leading = &shapes[..batch_len.min(STATIC_SPAN_MAX_SHAPES).min(shapes.len())];
2813        if leading.is_empty() {
2814            self.forget_observation();
2815            return StaticSpanDecision::Pass;
2816        }
2817        if !static_span_fullscreen_opaque(&leading[0], width, height) {
2818            self.forget_observation();
2819            return StaticSpanDecision::Pass;
2820        }
2821        // The span ends at the first shape the capture pass could not
2822        // reproduce through the plain instanced arm (rim-mesh candidates).
2823        let mut eligible = 1;
2824        while eligible < leading.len() && rim_mesh_band(&leading[eligible]).is_none() {
2825            eligible += 1;
2826        }
2827        let leading = &leading[..eligible];
2828        let clear_key = [
2829            clear.r.to_bits(),
2830            clear.g.to_bits(),
2831            clear.b.to_bits(),
2832            clear.a.to_bits(),
2833        ];
2834
2835        let key_len = self.key_shapes.len();
2836        let valid = self.texture.is_some()
2837            && key_len > 0
2838            && key_len <= leading.len()
2839            && self.key_width == width
2840            && self.key_height == height
2841            && self.key_clear == clear_key
2842            && self.key_has_gradient == has_gradient
2843            && span_records_equal(
2844                &self.key_shapes,
2845                &leading[..key_len],
2846                &self.key_gradients,
2847                gradients,
2848            );
2849
2850        // Stability probe, shared by miss-capture and hit-upgrade: the
2851        // longest leading run whose record AND gradient bytes repeat from
2852        // last frame.
2853        let mut stable = 0;
2854        while stable < leading.len()
2855            && stable < self.prev_shapes.len()
2856            && span_records_equal(
2857                &self.prev_shapes[stable..stable + 1],
2858                &leading[stable..stable + 1],
2859                &self.prev_gradients,
2860                gradients,
2861            )
2862        {
2863            stable += 1;
2864        }
2865        self.remember_observation(leading, gradients);
2866
2867        if valid {
2868            // Upgrade hysteresis: a valid span may EXTEND (a partial
2869            // invalidation — say a vignette-only palette change — shrank an
2870            // earlier capture, and the tail has stabilized again) only after
2871            // the extension repeats for a full window of consecutive
2872            // frames. Without it, a leading shape animating with a period
2873            // of a few frames would alternate upgrade-capture and
2874            // shrink-capture forever — capture-churn instead of caching.
2875            // The initial capture below takes no window because the whole
2876            // span stabilizing at once is the cold-start common case. No
2877            // gradient gate here: the stable prefix contains the key, and
2878            // every stored key carries a gradient record.
2879            if stable > key_len {
2880                self.extension_stable_frames += 1;
2881                if self.extension_stable_frames >= STATIC_SPAN_UPGRADE_FRAMES {
2882                    self.extension_stable_frames = 0;
2883                    return StaticSpanDecision::Capture { len: stable, clear };
2884                }
2885            } else {
2886                self.extension_stable_frames = 0;
2887            }
2888            self.hits += 1;
2889            if self.hits.is_multiple_of(600) {
2890                log::debug!(
2891                    "[static-span] {} hits / {} recaptures lifetime (span {} shapes, {}x{})",
2892                    self.hits,
2893                    self.recaptures,
2894                    key_len,
2895                    width,
2896                    height,
2897                );
2898            }
2899            return StaticSpanDecision::Hit { skip: key_len };
2900        }
2901
2902        self.extension_stable_frames = 0;
2903        // Engagement economics: a candidate span with no gradient records
2904        // would replace the cheapest fill there is (solid quads) with a
2905        // same-size texture blit — a wash at best on a mobile GPU, plus a
2906        // held full-target texture and a capture pass. The fill this stage
2907        // chases is the gradient+dither span, so a capture must carry at
2908        // least one gradient record. This also keeps solid-background-only
2909        // frames (most non-game screens) from ever paying an offscreen
2910        // acquire.
2911        if stable == 0 || span_gradient_len(&leading[..stable]) == 0 {
2912            return StaticSpanDecision::Pass;
2913        }
2914        StaticSpanDecision::Capture { len: stable, clear }
2915    }
2916
2917    /// Stores this frame's leading run for next frame's stability probe.
2918    fn remember_observation(&mut self, leading: &[ShapeData], gradients: &[GradientStop]) {
2919        self.prev_shapes.clear();
2920        self.prev_shapes.extend_from_slice(leading);
2921        let stop_len = span_gradient_len(leading);
2922        self.prev_gradients.clear();
2923        self.prev_gradients
2924            .extend_from_slice(&gradients[..stop_len]);
2925    }
2926
2927    fn forget_observation(&mut self) {
2928        self.prev_shapes.clear();
2929        self.prev_gradients.clear();
2930        self.extension_stable_frames = 0;
2931    }
2932
2933    /// Adopts a freshly captured span as the validity key. The caller has
2934    /// already encoded the capture pass into `texture`.
2935    #[allow(clippy::too_many_arguments)]
2936    fn store_key(
2937        &mut self,
2938        span: &[ShapeData],
2939        gradients: &[GradientStop],
2940        width: u32,
2941        height: u32,
2942        clear: wgpu::Color,
2943        has_gradient: bool,
2944    ) {
2945        self.key_shapes.clear();
2946        self.key_shapes.extend_from_slice(span);
2947        let stop_len = span_gradient_len(span);
2948        self.key_gradients.clear();
2949        self.key_gradients.extend_from_slice(&gradients[..stop_len]);
2950        self.key_width = width;
2951        self.key_height = height;
2952        self.key_clear = [
2953            clear.r.to_bits(),
2954            clear.g.to_bits(),
2955            clear.b.to_bits(),
2956            clear.a.to_bits(),
2957        ];
2958        self.key_has_gradient = has_gradient;
2959        self.recaptures += 1;
2960        if self.recaptures.is_multiple_of(64) || self.recaptures == 1 {
2961            log::debug!(
2962                "[static-span] recapture #{} (span {} shapes, {} stops, {}x{}; {} hits lifetime)",
2963                self.recaptures,
2964                self.key_shapes.len(),
2965                self.key_gradients.len(),
2966                width,
2967                height,
2968                self.hits,
2969            );
2970        }
2971    }
2972}
2973
2974/// Total gradient stops a leading span consumes. The span is a prefix of
2975/// the fused upload, so its stop payload is exactly the leading
2976/// `sum(gradient_count)` entries of the scratch gradient buffer.
2977#[cfg(not(target_arch = "wasm32"))]
2978fn span_gradient_len(span: &[ShapeData]) -> usize {
2979    span.iter().map(|shape| shape.gradient_count as usize).sum()
2980}
2981
2982/// Byte equality of two span record runs INCLUDING their gradient stop
2983/// payloads. Each record's stops live at
2984/// `gradient_start..gradient_start + gradient_count` in its frame's leading
2985/// gradient buffer; `gradient_start`/`gradient_count` are part of the
2986/// memcmp'd record bytes, so matching records address matching stop ranges
2987/// in both buffers.
2988#[cfg(not(target_arch = "wasm32"))]
2989fn span_records_equal(
2990    expected: &[ShapeData],
2991    actual: &[ShapeData],
2992    expected_gradients: &[GradientStop],
2993    actual_gradients: &[GradientStop],
2994) -> bool {
2995    if bytemuck::cast_slice::<ShapeData, u8>(expected)
2996        != bytemuck::cast_slice::<ShapeData, u8>(actual)
2997    {
2998        return false;
2999    }
3000    for shape in expected {
3001        let start = shape.gradient_start as usize;
3002        let end = start + shape.gradient_count as usize;
3003        if end > expected_gradients.len() || end > actual_gradients.len() {
3004            return false;
3005        }
3006        if bytemuck::cast_slice::<GradientStop, u8>(&expected_gradients[start..end])
3007            != bytemuck::cast_slice::<GradientStop, u8>(&actual_gradients[start..end])
3008        {
3009            return false;
3010        }
3011    }
3012    true
3013}
3014
3015/// Whether a converted record is the full-screen opaque base the span
3016/// mechanism keys on: a plain solid fill (no stroke, no arc, no gradient,
3017/// no clip, no corner rounding) whose axis-aligned quad covers the whole
3018/// `width` x `height` target with alpha exactly 1.0. Soundness does not
3019/// strictly need full coverage — the opaque clear already makes the
3020/// composite alpha 255 — but requiring the measured scene shape keeps the
3021/// cache from engaging on frames whose leading draw is not the static
3022/// background this stage was built for.
3023#[cfg(not(target_arch = "wasm32"))]
3024fn static_span_fullscreen_opaque(shape: &ShapeData, width: u32, height: u32) -> bool {
3025    if shape.brush_type != 0 || shape.gradient_count != 0 {
3026        return false;
3027    }
3028    if shape.color[3] != 1.0 {
3029        return false;
3030    }
3031    if shape.clip_rect != [0.0; 4] || shape.stroke_params != [0.0; 4] || shape.radii != [0.0; 4] {
3032        return false;
3033    }
3034    // Same corner layout as `rim_mesh_band`: quad01 = TL.xy, TR.xy;
3035    // quad23 = BL.xy, BR.xy.
3036    let [left, top, right, top_right_y] = shape.quad01;
3037    let [bl_x, bottom, br_x, br_y] = shape.quad23;
3038    let axis_aligned = top_right_y == top
3039        && bl_x == left
3040        && br_x == right
3041        && br_y == bottom
3042        && left < right
3043        && top < bottom;
3044    axis_aligned && left <= 0.0 && top <= 0.0 && right >= width as f32 && bottom >= height as f32
3045}
3046
3047/// Emits the quad `vs_main` would expand for this shape as four shared
3048/// vertices plus the index pattern (0, 1, 2)(2, 1, 3) — the identical corner
3049/// order, corner uvs and positions straight from the captured quad, so a
3050/// passthrough shape rasterizes bit-identically to the quad-expansion
3051/// indexless path while spending four vertex executions instead of six.
3052#[cfg(not(target_arch = "wasm32"))]
3053fn emit_passthrough_quad(
3054    shape: &ShapeData,
3055    shape_idx: u32,
3056    vertices: &mut Vec<MeshVertex>,
3057    indices: &mut Vec<u32>,
3058) {
3059    let base = vertices.len() as u32;
3060    let corners = [
3061        ([shape.quad01[0], shape.quad01[1]], [0.0, 0.0]),
3062        ([shape.quad01[2], shape.quad01[3]], [1.0, 0.0]),
3063        ([shape.quad23[0], shape.quad23[1]], [0.0, 1.0]),
3064        ([shape.quad23[2], shape.quad23[3]], [1.0, 1.0]),
3065    ];
3066    for (position, uv) in corners {
3067        vertices.push(MeshVertex {
3068            position,
3069            uv,
3070            shape_idx,
3071        });
3072    }
3073    indices.extend([0u32, 1, 2, 2, 1, 3].map(|corner| base + corner));
3074}
3075
3076/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
3077///
3078/// Two properties the byte-exactness bar depends on:
3079/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
3080///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
3081///   bitwise on the clip line;
3082/// * the intersection is computed on the lexicographically ordered endpoint
3083///   pair, so the shared radial edge of two adjacent trapezoids — traversed
3084///   in opposite directions — clips to bitwise-identical points, keeping the
3085///   strip watertight (no pixel shaded twice or missed along the seam).
3086#[cfg(not(target_arch = "wasm32"))]
3087fn clip_polygon_axis(
3088    input: &[[f32; 2]],
3089    axis: usize,
3090    bound: f32,
3091    keep_at_most: bool,
3092    output: &mut Vec<[f32; 2]>,
3093) {
3094    output.clear();
3095    let inside = |p: [f32; 2]| {
3096        if keep_at_most {
3097            p[axis] <= bound
3098        } else {
3099            p[axis] >= bound
3100        }
3101    };
3102    let intersect = |a: [f32; 2], b: [f32; 2]| {
3103        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
3104            (b, a)
3105        } else {
3106            (a, b)
3107        };
3108        let t = (bound - p[axis]) / (q[axis] - p[axis]);
3109        let mut point = [0.0f32; 2];
3110        point[axis] = bound;
3111        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
3112        point
3113    };
3114    for (index, &current) in input.iter().enumerate() {
3115        let previous = input[(index + input.len() - 1) % input.len()];
3116        match (inside(previous), inside(current)) {
3117            (true, true) => output.push(current),
3118            (true, false) => output.push(intersect(previous, current)),
3119            (false, true) => {
3120                output.push(intersect(previous, current));
3121                output.push(current);
3122            }
3123            (false, false) => {}
3124        }
3125    }
3126}
3127
3128/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
3129///
3130/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
3131/// triangles is a superset of `{ p in the capture quad's box :
3132/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
3133/// Over-inclusion is free (the SDF discards those pixels identically to
3134/// today's quad); only under-inclusion can diverge, and
3135/// `arc_mesh_contains_every_band_pixel` checks it never happens.
3136///
3137/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
3138/// tangent-outside the dilated outer circle; inner vertices ride at the
3139/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
3140/// bounded by the round-cap disc about the band endpoint (butt/square caps
3141/// only cut that disc with planes — see `sdf_arc_band`), so padding the
3142/// angular range by the disc's angular half-extent contains every cap. Each
3143/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
3144/// a trapezoid the clipper left untouched shares its two boundary vertices
3145/// with each neighbor through the index list (closed rings wrap the sharing
3146/// modulo the boundary count), so the strip is watertight by construction —
3147/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
3148/// per-arc vertex count collapses from three-per-triangle to the unique
3149/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
3150/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
3151/// the shared block and indexed directly; seams against neighbors still hold
3152/// because a boundary edge either survives the clip on both sides
3153/// bitwise-identically (same input edge, same planes, same float ops — see
3154/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
3155/// emitted in exact segment order either way, so the indexed mesh's
3156/// primitive stream is triangle-for-triangle the one the non-indexed
3157/// emitter produced.
3158///
3159/// Returns the emitted segment count, or `None` when the mesh came out empty
3160/// — the caller emits the passthrough quad instead (never risk
3161/// under-coverage).
3162#[cfg(not(target_arch = "wasm32"))]
3163fn emit_arc_band_mesh(
3164    shape: &ShapeData,
3165    shape_idx: u32,
3166    band: &ArcMeshBand,
3167    vertices: &mut Vec<MeshVertex>,
3168    indices: &mut Vec<u32>,
3169) -> Option<usize> {
3170    let [cx, cy] = band.center;
3171    let ra = (band.outer + band.inner) * 0.5;
3172    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
3173    let rb_m = rb + ARC_MESH_MARGIN;
3174    let ro = ra + rb_m;
3175    let ri = (ra - rb_m).max(0.0);
3176    let tau = cranpose_ui_graphics::TAU;
3177
3178    let (range_start, range) = if band.sweep >= tau {
3179        (0.0, tau)
3180    } else {
3181        let pad = if rb_m < ra {
3182            (rb_m / ra).asin() + 0.05
3183        } else {
3184            // The cap disc wraps the center; such shapes are tiny, take the
3185            // whole circle.
3186            std::f32::consts::PI
3187        };
3188        let padded = band.sweep + pad + pad;
3189        if padded >= tau {
3190            (0.0, tau)
3191        } else {
3192            (band.start - pad, padded)
3193        }
3194    };
3195    let closed = range >= tau;
3196
3197    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
3198    let segments =
3199        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
3200    let step = range / segments as f32;
3201    let rc = ro / (step * 0.5).cos();
3202
3203    // Boundary vertices are computed once and shared by both adjacent
3204    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
3205    // fill rule shade each seam exactly once.
3206    let boundary_count = if closed { segments } else { segments + 1 };
3207    let mut boundaries = Vec::with_capacity(boundary_count);
3208    for j in 0..boundary_count {
3209        let (sin, cos) = (range_start + step * j as f32).sin_cos();
3210        boundaries.push((
3211            [cx + cos * ri, cy + sin * ri],
3212            [cx + cos * rc, cy + sin * rc],
3213        ));
3214    }
3215
3216    let quad_min = [shape.quad01[0], shape.quad01[1]];
3217    let quad_max = [shape.quad23[2], shape.quad23[3]];
3218
3219    /// One trapezoid's clip outcome (see the function docs): `Shared` means
3220    /// the clip output is bitwise the input quad, so its corners index the
3221    /// shared boundary block; `Fan` carries the clipped polygon for private
3222    /// fan triangulation; `Empty` was clipped away entirely.
3223    enum SegmentGeometry {
3224        Shared,
3225        Fan(Vec<[f32; 2]>),
3226        Empty,
3227    }
3228
3229    // Phase 1: clip every trapezoid and classify it.
3230    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
3231    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
3232    let mut segment_geometry = Vec::with_capacity(segments);
3233    let mut boundary_used = vec![false; boundary_count];
3234    for j in 0..segments {
3235        let jb = (j + 1) % boundary_count;
3236        let (inner_a, outer_a) = boundaries[j];
3237        let (inner_b, outer_b) = boundaries[jb];
3238        polygon.clear();
3239        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
3240        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
3241        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
3242        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
3243        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
3244        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
3245        // center) before fanning.
3246        scratch.clear();
3247        for &point in polygon.iter() {
3248            if scratch.last() != Some(&point) {
3249                scratch.push(point);
3250            }
3251        }
3252        while scratch.len() > 1 && scratch.first() == scratch.last() {
3253            scratch.pop();
3254        }
3255        if scratch.len() < 3 {
3256            segment_geometry.push(SegmentGeometry::Empty);
3257        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
3258            boundary_used[j] = true;
3259            boundary_used[jb] = true;
3260            segment_geometry.push(SegmentGeometry::Shared);
3261        } else {
3262            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
3263        }
3264    }
3265
3266    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
3267        let index = vertices.len() as u32;
3268        vertices.push(MeshVertex {
3269            position,
3270            uv: [
3271                (position[0] - shape.rect[0]) / shape.rect[2],
3272                (position[1] - shape.rect[1]) / shape.rect[3],
3273            ],
3274            shape_idx,
3275        });
3276        index
3277    };
3278
3279    // Shared block: every boundary referenced by a surviving whole trapezoid
3280    // gets its (inner, outer) vertex pair exactly once, in boundary order.
3281    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
3282    for (j, used) in boundary_used.iter().enumerate() {
3283        if *used {
3284            let (inner, outer) = boundaries[j];
3285            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
3286        }
3287    }
3288
3289    // Phase 2: indices in exact segment order — the primitive stream matches
3290    // the non-indexed emitter triangle for triangle.
3291    let start_len = indices.len();
3292    for (j, geometry) in segment_geometry.iter().enumerate() {
3293        match geometry {
3294            SegmentGeometry::Empty => {}
3295            SegmentGeometry::Shared => {
3296                let jb = (j + 1) % boundary_count;
3297                let [in_a, out_a] = boundary_vertex[j];
3298                let [in_b, out_b] = boundary_vertex[jb];
3299                // The fan the non-indexed emitter produced for an untouched
3300                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
3301                // same quad diagonal.
3302                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
3303            }
3304            SegmentGeometry::Fan(points) => {
3305                let base = vertices.len() as u32;
3306                for &point in points {
3307                    push_vertex(vertices, point);
3308                }
3309                for i in 1..points.len() as u32 - 1 {
3310                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
3311                }
3312            }
3313        }
3314    }
3315    if indices.len() == start_len {
3316        return None;
3317    }
3318    Some(segments)
3319}
3320
3321/// Unsigned shoelace area of an emitted indexed triangle list, for
3322/// telemetry.
3323#[cfg(not(target_arch = "wasm32"))]
3324fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
3325    indices
3326        .as_chunks::<3>()
3327        .0
3328        .iter()
3329        .map(|tri| {
3330            let [a, b, c] = [
3331                vertices[tri[0] as usize].position,
3332                vertices[tri[1] as usize].position,
3333                vertices[tri[2] as usize].position,
3334            ];
3335            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
3336                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
3337            cross.abs() * 0.5
3338        })
3339        .sum()
3340}
3341
3342/// Unsigned area of the two triangles the quad-expansion path would rasterize for
3343/// this shape, for telemetry.
3344#[cfg(not(target_arch = "wasm32"))]
3345fn quad_shoelace_area(shape: &ShapeData) -> f64 {
3346    let corners = [
3347        [shape.quad01[0] as f64, shape.quad01[1] as f64],
3348        [shape.quad01[2] as f64, shape.quad01[3] as f64],
3349        [shape.quad23[0] as f64, shape.quad23[1] as f64],
3350        [shape.quad23[2] as f64, shape.quad23[3] as f64],
3351    ];
3352    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
3353        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
3354    };
3355    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
3356}
3357
3358/// `CRANPOSE_FILL_DIAG` (`debug.cranpose.fill_diag` on Android): per-frame
3359/// CPU-side accounting of the fill area the renderer submits, in device px².
3360/// Off by default; any set value except "0" enables. Read once per process,
3361/// so a disabled hot path pays one static load and a branch.
3362#[cfg(not(target_arch = "wasm32"))]
3363pub(crate) fn fill_area_diag_enabled() -> bool {
3364    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3365    *ENABLED.get_or_init(
3366        || matches!(std::env::var("CRANPOSE_FILL_DIAG").as_deref(), Ok(value) if value != "0"),
3367    )
3368}
3369
3370/// Rendered frames aggregated into one `[fill-diag]` report line.
3371#[cfg(not(target_arch = "wasm32"))]
3372const FILL_DIAG_WINDOW_FRAMES: u32 = 120;
3373
3374#[cfg(not(target_arch = "wasm32"))]
3375const FILL_DIAG_BUCKETS: usize = 9;
3376
3377/// Opacity class of a shape's fill for the `[fill-truth]` histogram, decided
3378/// from the CONVERTED record: a solid brush with vertex alpha exactly 1.0 is
3379/// opaque, any other solid is translucent, and every gradient counts as
3380/// non-solid (its stops can each carry their own alpha). Retained shapes are
3381/// classified from their capture-time colors — a later recolor patch through
3382/// the slot's paint buffer is not re-classified.
3383#[cfg(not(target_arch = "wasm32"))]
3384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3385enum FillOpacityClass {
3386    Opaque = 0,
3387    Translucent = 1,
3388    NonSolid = 2,
3389}
3390
3391#[cfg(not(target_arch = "wasm32"))]
3392fn fill_opacity_class(shape: &ShapeData) -> FillOpacityClass {
3393    if shape.brush_type != 0 {
3394        FillOpacityClass::NonSolid
3395    } else if shape.color[3] == 1.0 {
3396        FillOpacityClass::Opaque
3397    } else {
3398        FillOpacityClass::Translucent
3399    }
3400}
3401
3402/// The fill-diag bucket of a batched shape quad, decoded from the packed
3403/// flags the way the fragment shader decodes them (`u32(max(x, 0.0)) & 3`).
3404/// Fills keep real corner radii in `radii` (arcs reuse the field for trig,
3405/// but they take the arc arm first).
3406#[cfg(not(target_arch = "wasm32"))]
3407fn fill_diag_bucket(shape: &ShapeData) -> usize {
3408    match shape.stroke_params[1].max(0.0) as u32 & 3 {
3409        SHAPE_KIND_ARC => FillAreaDiag::ARC,
3410        SHAPE_KIND_STROKE => FillAreaDiag::RRECT_STROKE,
3411        _ if shape.radii.iter().any(|radius| *radius > 0.0) => FillAreaDiag::RRECT_FILL,
3412        _ => FillAreaDiag::RECT,
3413    }
3414}
3415
3416#[cfg(not(target_arch = "wasm32"))]
3417fn fill_diag_bucket_name(bucket: usize) -> &'static str {
3418    match bucket {
3419        FillAreaDiag::ARC => "arc",
3420        FillAreaDiag::RRECT_STROKE => "rrect-stroke",
3421        FillAreaDiag::RRECT_FILL => "rrect-fill",
3422        FillAreaDiag::RECT => "rect",
3423        FillAreaDiag::MESH => "mesh",
3424        FillAreaDiag::RETAINED => "retained",
3425        FillAreaDiag::IMAGE_GLYPH => "img+glyph",
3426        FillAreaDiag::EFFECT_COMPOSITE => "effect-comp",
3427        FillAreaDiag::OFFSCREEN_SOURCE => "offscr-src",
3428        _ => "?",
3429    }
3430}
3431
3432/// Analytic covered area of a shape in device px² — the pixels the SDF will
3433/// actually keep, as opposed to the bounding quad it is rasterized with —
3434/// decoded from the same converted `ShapeData` fields the classifier and the
3435/// band-mesh builders read. Deliberately closed-form per class:
3436///
3437/// * arc / annular sector: `sweep · r_mid · thickness` plus the endcap area
3438///   (two half-discs for round caps; square caps rasterize the same pixel
3439///   measure — `sdf_arc_band` cuts the endpoint disc at `plane − rb`, which
3440///   removes nothing but the tangent point; butt caps add nothing; a closed
3441///   ring has no caps).
3442/// * stroked round-rect: centerline perimeter × stroke width — exact while
3443///   every corner radius ≥ half the stroke width (the offset-band identity);
3444///   miter corner spurs at sharp corners are not modeled.
3445/// * round-rect / circle fill: `w·h − (1 − π/4)·Σ rᵢ²`, radii clamped to the
3446///   half-extent (a circle degenerates to exactly `π r²`).
3447/// * plain rect: the submitted quad IS the covered set — priced at the quad
3448///   area by the caller, this function returns `w·h` (equal under any
3449///   similarity).
3450///
3451/// Clips and viewport scissors are not modeled, same as the quad accounting.
3452#[cfg(not(target_arch = "wasm32"))]
3453fn analytic_covered_area(shape: &ShapeData) -> f64 {
3454    let flags = shape.stroke_params[1].max(0.0) as u32;
3455    match flags & 3 {
3456        SHAPE_KIND_ARC => {
3457            let outer = f64::from(shape.stroke_params[2]).max(0.0);
3458            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
3459            let tau = f64::from(cranpose_ui_graphics::TAU);
3460            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
3461            let thickness = outer - inner;
3462            let band = sweep * 0.5 * (outer + inner) * thickness;
3463            let caps = if sweep >= tau {
3464                0.0
3465            } else {
3466                match (flags >> 2) & 3 {
3467                    // Round and square: two half-discs of radius t/2 — the
3468                    // shader's square cap keeps the endpoint disc's measure
3469                    // (see the doc comment).
3470                    1 | 2 => std::f64::consts::PI * (thickness * 0.5) * (thickness * 0.5),
3471                    _ => 0.0,
3472                }
3473            };
3474            band + caps
3475        }
3476        SHAPE_KIND_STROKE => {
3477            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
3478            // `rect` for a stroked shape is the stroke-inflated box.
3479            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
3480            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
3481            let max_radius = geom_w.min(geom_h) * 0.5;
3482            let radii_sum: f64 = shape
3483                .radii
3484                .iter()
3485                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
3486                .sum();
3487            let perimeter =
3488                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
3489            perimeter.max(0.0) * stroke_width
3490        }
3491        _ => {
3492            let width = f64::from(shape.rect[2]).max(0.0);
3493            let height = f64::from(shape.rect[3]).max(0.0);
3494            let max_radius = width.min(height) * 0.5;
3495            let radii_sq: f64 = shape
3496                .radii
3497                .iter()
3498                .map(|radius| {
3499                    let radius = f64::from(*radius).clamp(0.0, max_radius);
3500                    radius * radius
3501                })
3502                .sum();
3503            width * height - (1.0 - std::f64::consts::FRAC_PI_4) * radii_sq
3504        }
3505    }
3506}
3507
3508/// Antialiasing allowance added on top of [`analytic_covered_area`]: the SDF
3509/// feathers over roughly one pixel of boundary, so ~1 px × the covered set's
3510/// perimeter approximates the partially-lit fringe. Plain rects get none
3511/// (their quad is exact); a stroked shape has two boundary curves, whose
3512/// perimeters sum to twice the centerline perimeter for a convex outline.
3513#[cfg(not(target_arch = "wasm32"))]
3514fn aa_perimeter_allowance(shape: &ShapeData) -> f64 {
3515    let flags = shape.stroke_params[1].max(0.0) as u32;
3516    match flags & 3 {
3517        SHAPE_KIND_ARC => {
3518            let outer = f64::from(shape.stroke_params[2]).max(0.0);
3519            let inner = f64::from(shape.stroke_params[3]).clamp(0.0, outer);
3520            let tau = f64::from(cranpose_ui_graphics::TAU);
3521            let sweep = f64::from(shape.arc_params[3]).clamp(0.0, tau);
3522            let ends = if sweep >= tau {
3523                0.0
3524            } else {
3525                2.0 * (outer - inner)
3526            };
3527            sweep * (outer + inner) + ends
3528        }
3529        SHAPE_KIND_STROKE => {
3530            let stroke_width = f64::from(shape.stroke_params[0]).max(0.0);
3531            let geom_w = (f64::from(shape.rect[2]) - stroke_width).max(0.0);
3532            let geom_h = (f64::from(shape.rect[3]) - stroke_width).max(0.0);
3533            let max_radius = geom_w.min(geom_h) * 0.5;
3534            let radii_sum: f64 = shape
3535                .radii
3536                .iter()
3537                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
3538                .sum();
3539            let perimeter =
3540                2.0 * (geom_w + geom_h) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum;
3541            2.0 * perimeter.max(0.0)
3542        }
3543        _ if shape.radii.iter().any(|radius| *radius > 0.0) => {
3544            let width = f64::from(shape.rect[2]).max(0.0);
3545            let height = f64::from(shape.rect[3]).max(0.0);
3546            let max_radius = width.min(height) * 0.5;
3547            let radii_sum: f64 = shape
3548                .radii
3549                .iter()
3550                .map(|radius| f64::from(*radius).clamp(0.0, max_radius))
3551                .sum();
3552            (2.0 * (width + height) - (2.0 - std::f64::consts::FRAC_PI_2) * radii_sum).max(0.0)
3553        }
3554        _ => 0.0,
3555    }
3556}
3557
3558/// Analytic lit area: covered pixels plus the AA fringe allowance. Callers
3559/// clamp it to the shape's submitted area — the shader cannot light pixels
3560/// its quad never rasterizes.
3561#[cfg(not(target_arch = "wasm32"))]
3562fn analytic_lit_area(shape: &ShapeData) -> f64 {
3563    analytic_covered_area(shape) + aa_perimeter_allowance(shape)
3564}
3565
3566/// Device-space AABB of a shape's submitted quad: min x, min y, max x, max y.
3567#[cfg(not(target_arch = "wasm32"))]
3568fn quad_aabb(shape: &ShapeData) -> [f64; 4] {
3569    let xs = [
3570        f64::from(shape.quad01[0]),
3571        f64::from(shape.quad01[2]),
3572        f64::from(shape.quad23[0]),
3573        f64::from(shape.quad23[2]),
3574    ];
3575    let ys = [
3576        f64::from(shape.quad01[1]),
3577        f64::from(shape.quad01[3]),
3578        f64::from(shape.quad23[1]),
3579        f64::from(shape.quad23[3]),
3580    ];
3581    let fold = |values: [f64; 4], pick: fn(f64, f64) -> f64| {
3582        values.into_iter().reduce(pick).unwrap_or(0.0)
3583    };
3584    [
3585        fold(xs, f64::min),
3586        fold(ys, f64::min),
3587        fold(xs, f64::max),
3588        fold(ys, f64::max),
3589    ]
3590}
3591
3592/// Vertical strips of the midpoint rule used by
3593/// [`area_outside_inscribed_circle`]. 32 strips keep the chord error under
3594/// ~0.5% for a full-viewport quad — plenty for a corner-waste ratio.
3595#[cfg(not(target_arch = "wasm32"))]
3596const CORNER_FILL_STRIPS: usize = 32;
3597
3598/// Area of an axis-aligned box lying inside the viewport but OUTSIDE the
3599/// inscribed circle (diameter `min(w, h)`, centered) — the pixels a round
3600/// watch display physically cannot show. Approximations, deliberate: the
3601/// submitted quad is replaced by its AABB (exact for the axis-aligned quads
3602/// that dominate full-frame scenes), and the circle chord is integrated with
3603/// [`CORNER_FILL_STRIPS`] midpoint strips instead of closed-form segments.
3604/// On a non-square viewport the side bands beyond the circle count as
3605/// outside too, which is the honest answer for a round display.
3606#[cfg(not(target_arch = "wasm32"))]
3607fn area_outside_inscribed_circle(aabb: [f64; 4], viewport: (u32, u32)) -> f64 {
3608    let viewport_w = f64::from(viewport.0);
3609    let viewport_h = f64::from(viewport.1);
3610    if viewport_w <= 0.0 || viewport_h <= 0.0 {
3611        return 0.0;
3612    }
3613    let x0 = aabb[0].max(0.0);
3614    let y0 = aabb[1].max(0.0);
3615    let x1 = aabb[2].min(viewport_w);
3616    let y1 = aabb[3].min(viewport_h);
3617    if x1 <= x0 || y1 <= y0 {
3618        return 0.0;
3619    }
3620    let center_x = viewport_w * 0.5;
3621    let center_y = viewport_h * 0.5;
3622    let radius = viewport_w.min(viewport_h) * 0.5;
3623    let strip = (x1 - x0) / CORNER_FILL_STRIPS as f64;
3624    let mut outside = 0.0;
3625    for index in 0..CORNER_FILL_STRIPS {
3626        let x = x0 + (index as f64 + 0.5) * strip;
3627        let dx = x - center_x;
3628        let chord_sq = radius * radius - dx * dx;
3629        let inside = if chord_sq > 0.0 {
3630            let half_chord = chord_sq.sqrt();
3631            (y1.min(center_y + half_chord) - y0.max(center_y - half_chord)).max(0.0)
3632        } else {
3633            0.0
3634        };
3635        outside += ((y1 - y0) - inside) * strip;
3636    }
3637    outside
3638}
3639
3640/// Per-shape fill-diag record a replay slot retains at capture, so retained
3641/// draws can be priced per range without re-deriving anything per frame.
3642/// Only built while `CRANPOSE_FILL_DIAG` is on.
3643#[cfg(not(target_arch = "wasm32"))]
3644#[derive(Clone, Copy, Debug)]
3645struct FillDiagShapeRecord {
3646    /// Capture-space area actually submitted for this shape: mesh triangle
3647    /// area when the slot replays its arc mesh, bounding-quad area otherwise.
3648    drawn_px2: f64,
3649    /// Analytic lit area ([`analytic_lit_area`]), clamped to `drawn_px2`.
3650    lit_px2: f64,
3651    /// SDF-class bucket ([`fill_diag_bucket`]), for the top-slack dump.
3652    bucket: usize,
3653    opacity: FillOpacityClass,
3654    /// Capture-space AABB of the submitted quad, for the corner counter.
3655    aabb: [f64; 4],
3656}
3657
3658/// Builds a capture's fill-diag records. `mesh` carries the kept arc mesh's
3659/// `(vertices, indices, index_prefix)` when the slot will replay it, so each
3660/// shape is priced by its true triangle area.
3661#[cfg(not(target_arch = "wasm32"))]
3662fn fill_diag_capture_records(
3663    shape_data: &[ShapeData],
3664    mesh: Option<(&[MeshVertex], &[u32], &[u32])>,
3665) -> Vec<FillDiagShapeRecord> {
3666    shape_data
3667        .iter()
3668        .enumerate()
3669        .map(|(index, shape)| {
3670            let drawn_px2 = match mesh {
3671                Some((vertices, indices, index_prefix)) => {
3672                    let start = index_prefix[index] as usize;
3673                    let end = index_prefix[index + 1] as usize;
3674                    triangles_shoelace_area(vertices, &indices[start..end])
3675                }
3676                None => quad_shoelace_area(shape),
3677            };
3678            FillDiagShapeRecord {
3679                drawn_px2,
3680                lit_px2: analytic_lit_area(shape).clamp(0.0, drawn_px2),
3681                bucket: fill_diag_bucket(shape),
3682                opacity: fill_opacity_class(shape),
3683                aabb: quad_aabb(shape),
3684            }
3685        })
3686        .collect()
3687}
3688
3689/// One entry of the once-per-process top-slack dump: a retained shape whose
3690/// submitted area most exceeds its lit area.
3691#[cfg(not(target_arch = "wasm32"))]
3692#[derive(Clone, Copy, Debug)]
3693struct FillDiagSlackEntry {
3694    slot: u32,
3695    shape: u32,
3696    bucket: usize,
3697    drawn_px2: f64,
3698    lit_px2: f64,
3699}
3700
3701#[cfg(not(target_arch = "wasm32"))]
3702const FILL_DIAG_SLACK_TOP: usize = 10;
3703
3704/// Submitted-fill-area accounting behind [`fill_area_diag_enabled`]. The
3705/// watch's GPU counters are sepolicy-blocked, but the renderer knows every
3706/// quad it emits, so summing their areas per bucket says where the fragment
3707/// work goes; the point is the RATIO between buckets, and several are
3708/// deliberately approximate where exactness would cost the hot path:
3709///
3710/// * `arc` / `rrect-stroke` / `rrect-fill` / `rect` — batched shape quads by
3711///   decoded SDF class: exact shoelace area of the submitted quads, from the
3712///   fused screen pass and the offscreen layer/shadow-source passes alike.
3713///   Scissors and the SDF's own discards are not modeled. The latched
3714///   instanced-quad path draws these same quads (one instance per shape), so
3715///   instanced draws live in these buckets rather than a separate one.
3716/// * `mesh` — transient rim band meshes: exact triangle area, replacing the
3717///   rim's bounding quad (which is subtracted back out of `rrect-stroke`).
3718/// * `retained` — replay-slot draws: exact capture-space area of the drawn
3719///   shape range (mesh triangles when the slot replays its arc mesh, quads
3720///   otherwise) times the draw's similarity scale squared.
3721/// * `img+glyph` — image quads exactly; glyph atlas quads as width x height.
3722///   A retained glyph run counts every quad of its cached buffer (the
3723///   shared path's per-quad viewport cull is not re-run for it).
3724/// * `effect-comp` — effect-renderer draws into a caller-supplied view:
3725///   composites/blits (incl. batched, projective and masked variants) and
3726///   src-over runtime shader passes. Priced per pass at the dest viewport
3727///   area, clamped by the scissor when one is set (min of the two areas
3728///   stands in for their exact intersection).
3729/// * `offscr-src` — passes rendering INTO offscreen chain textures: blur
3730///   ping-pong axis passes, offset passes, replace-mode shader passes, and
3731///   the shadow-source target passes of `encode_shadow_shape_source_passes`
3732///   (the whole bounds-sized target per pass — its load/store round trip —
3733///   on top of the shape quads it draws, which the SDF-class buckets price
3734///   as usual).
3735///
3736/// The `[fill-truth]` line splits every bucket into analytic lit vs slack
3737/// (`lit` per [`analytic_lit_area`], `slack = submitted − lit`, clamped
3738/// non-negative; effect passes are all-lit by definition), histograms lit
3739/// pixels by [`FillOpacityClass`] (shape buckets only — image/glyph and
3740/// effect fill has no CPU-known alpha and is excluded), and prices the
3741/// full-frame corner waste per [`area_outside_inscribed_circle`]. The corner
3742/// counter covers full-frame shape batches and identity-transform retained
3743/// draws; meshed rims stay priced by their bounding AABB there (documented
3744/// overcount), and image/glyph quads are excluded.
3745///
3746/// Not counted: frame-graph layer clears/attachments outside the effect
3747/// renderer's own draw sites.
3748#[cfg(not(target_arch = "wasm32"))]
3749#[derive(Default)]
3750struct FillAreaDiag {
3751    /// Current frame's per-bucket submitted area, device px². `Cell`s
3752    /// because draw encoding accumulates through `&self`, the same pattern
3753    /// as [`gpu_stats::FrameStats`].
3754    frame: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
3755    /// Current frame's per-bucket analytic lit area, ≤ the submitted area.
3756    frame_lit: [std::cell::Cell<f64>; FILL_DIAG_BUCKETS],
3757    /// Current frame's lit area by [`FillOpacityClass`], shape buckets only.
3758    frame_opacity: [std::cell::Cell<f64>; 3],
3759    /// Current frame's full-frame fill outside the inscribed circle.
3760    frame_corner: std::cell::Cell<f64>,
3761    /// The frame's surface size, latched by [`Self::reset_frame`] — the
3762    /// full-frame-pass gate and the inscribed circle both derive from it.
3763    viewport: std::cell::Cell<(u32, u32)>,
3764    /// Window totals, folded once per frame by [`Self::finish_frame`].
3765    window: [f64; FILL_DIAG_BUCKETS],
3766    window_lit: [f64; FILL_DIAG_BUCKETS],
3767    window_opacity: [f64; 3],
3768    window_corner: f64,
3769    window_frames: u32,
3770    /// Worst retained shapes by slack, collected at slot capture and dumped
3771    /// once with the first report window that has any (then dropped).
3772    slack_top: Vec<FillDiagSlackEntry>,
3773    slack_dumped: bool,
3774}
3775
3776#[cfg(not(target_arch = "wasm32"))]
3777impl FillAreaDiag {
3778    const ARC: usize = 0;
3779    const RRECT_STROKE: usize = 1;
3780    const RRECT_FILL: usize = 2;
3781    const RECT: usize = 3;
3782    const MESH: usize = 4;
3783    const RETAINED: usize = 5;
3784    const IMAGE_GLYPH: usize = 6;
3785    const EFFECT_COMPOSITE: usize = 7;
3786    const OFFSCREEN_SOURCE: usize = 8;
3787
3788    fn add(&self, bucket: usize, area_px2: f64) {
3789        let cell = &self.frame[bucket];
3790        cell.set(cell.get() + area_px2);
3791    }
3792
3793    fn add_lit(&self, bucket: usize, lit_px2: f64) {
3794        let cell = &self.frame_lit[bucket];
3795        cell.set(cell.get() + lit_px2);
3796    }
3797
3798    fn add_corner(&self, px2: f64) {
3799        self.frame_corner.set(self.frame_corner.get() + px2);
3800    }
3801
3802    /// Whether a batch's viewport IS this frame's surface — the gate for the
3803    /// corner counter (offscreen shadow/layer passes carry their own bounds
3804    /// viewport and never qualify).
3805    fn is_full_frame(&self, viewport: ViewportUniformParams) -> bool {
3806        let (width, height) = self.viewport.get();
3807        width > 0
3808            && height > 0
3809            && viewport.width == width
3810            && viewport.height == height
3811            && viewport.offset == [0.0, 0.0]
3812    }
3813
3814    /// Splits a freshly converted batch's quads by SDF class
3815    /// ([`fill_diag_bucket`]), alongside each bucket's analytic lit area,
3816    /// the opacity histogram and — for full-frame passes — the corner
3817    /// counter.
3818    fn add_shape_quads(&self, shapes: &[ShapeData], viewport: ViewportUniformParams) {
3819        let full_frame = self.is_full_frame(viewport);
3820        let frame_viewport = self.viewport.get();
3821        let mut buckets = [0.0_f64; FILL_DIAG_BUCKETS];
3822        let mut lit_buckets = [0.0_f64; FILL_DIAG_BUCKETS];
3823        let mut opacity = [0.0_f64; 3];
3824        let mut corner = 0.0_f64;
3825        for shape in shapes {
3826            let bucket = fill_diag_bucket(shape);
3827            let quad = quad_shoelace_area(shape);
3828            let lit = analytic_lit_area(shape).clamp(0.0, quad);
3829            buckets[bucket] += quad;
3830            lit_buckets[bucket] += lit;
3831            opacity[fill_opacity_class(shape) as usize] += lit;
3832            if full_frame {
3833                corner += area_outside_inscribed_circle(quad_aabb(shape), frame_viewport);
3834            }
3835        }
3836        for (bucket, area) in buckets.into_iter().enumerate() {
3837            if area > 0.0 {
3838                self.add(bucket, area);
3839            }
3840        }
3841        for (bucket, lit) in lit_buckets.into_iter().enumerate() {
3842            if lit > 0.0 {
3843                self.add_lit(bucket, lit);
3844            }
3845        }
3846        for (class, lit) in self.frame_opacity.iter().zip(opacity) {
3847            class.set(class.get() + lit);
3848        }
3849        if corner > 0.0 {
3850            self.add_corner(corner);
3851        }
3852    }
3853
3854    /// A leading-span cache hit replaced these already-counted quads with
3855    /// one cached-texture blit: subtract their submitted, lit and
3856    /// opacity-class areas back out — those pixels now arrive through the
3857    /// blit, an effect-renderer composite that the effect-comp bucket
3858    /// prices at its own draw site and the opacity histogram excludes by
3859    /// design (no CPU-known alpha). The corner counter stays as priced at
3860    /// batch prepare: the full-target blit writes the very same corner
3861    /// pixels, so the waste that counter exists to expose is unchanged.
3862    fn note_static_span_skip(&self, shapes: &[ShapeData]) {
3863        for shape in shapes {
3864            let bucket = fill_diag_bucket(shape);
3865            let quad = quad_shoelace_area(shape);
3866            let lit = analytic_lit_area(shape).clamp(0.0, quad);
3867            self.add(bucket, -quad);
3868            self.add_lit(bucket, -lit);
3869            let class = &self.frame_opacity[fill_opacity_class(shape) as usize];
3870            class.set(class.get() - lit);
3871        }
3872    }
3873
3874    /// A transient rim replaced its bounding quad with a band mesh: move the
3875    /// quad's area and lit (already counted at batch prepare) out of the
3876    /// stroke bucket and count the mesh triangles instead. The opacity
3877    /// histogram and corner counter stay as priced at batch prepare — the
3878    /// same pixels light up either way, and the corner counter deliberately
3879    /// keeps the quad AABB (documented overcount for meshed rims).
3880    fn note_rim_mesh(&self, shape: &ShapeData, mesh_px2: f64) {
3881        let quad = quad_shoelace_area(shape);
3882        let lit = analytic_lit_area(shape).clamp(0.0, quad);
3883        self.add(Self::RRECT_STROKE, -quad);
3884        self.add_lit(Self::RRECT_STROKE, -lit);
3885        self.add(Self::MESH, mesh_px2);
3886        self.add_lit(Self::MESH, lit.min(mesh_px2));
3887    }
3888
3889    /// One retained replay draw over `first..last` of a slot's capture:
3890    /// capture-space records times the draw's similarity scale squared. The
3891    /// corner counter only accumulates for identity-transform draws (rot 0,
3892    /// scale 1 — the static background/rings case it exists for), because a
3893    /// moved batch's capture-space AABBs no longer say where it lands.
3894    fn add_retained_range(
3895        &self,
3896        records: &[FillDiagShapeRecord],
3897        first: u32,
3898        last: u32,
3899        transform: &SimilarityTransform,
3900    ) {
3901        let Some(range) = records.get(first as usize..last as usize) else {
3902            return;
3903        };
3904        let scale = f64::from(transform.scale);
3905        let factor = scale * scale;
3906        let identity = transform.rot == [1.0, 0.0] && transform.scale == 1.0;
3907        let frame_viewport = self.viewport.get();
3908        let mut drawn = 0.0_f64;
3909        let mut lit = 0.0_f64;
3910        let mut opacity = [0.0_f64; 3];
3911        let mut corner = 0.0_f64;
3912        for record in range {
3913            drawn += record.drawn_px2;
3914            lit += record.lit_px2;
3915            opacity[record.opacity as usize] += record.lit_px2;
3916            if identity {
3917                corner += area_outside_inscribed_circle(record.aabb, frame_viewport);
3918            }
3919        }
3920        self.add(Self::RETAINED, drawn * factor);
3921        self.add_lit(Self::RETAINED, lit * factor);
3922        for (class, value) in self.frame_opacity.iter().zip(opacity) {
3923            class.set(class.get() + value * factor);
3924        }
3925        if corner > 0.0 {
3926            self.add_corner(corner);
3927        }
3928    }
3929
3930    /// Collects top-slack candidates from a fresh capture, keeping the
3931    /// [`FILL_DIAG_SLACK_TOP`] worst across all captures until the first
3932    /// report window dumps them.
3933    fn note_retained_capture(&mut self, slot: u32, records: &[FillDiagShapeRecord]) {
3934        if self.slack_dumped {
3935            return;
3936        }
3937        for (index, record) in records.iter().enumerate() {
3938            if record.drawn_px2 - record.lit_px2 <= 0.0 {
3939                continue;
3940            }
3941            self.slack_top.push(FillDiagSlackEntry {
3942                slot,
3943                shape: index as u32,
3944                bucket: record.bucket,
3945                drawn_px2: record.drawn_px2,
3946                lit_px2: record.lit_px2,
3947            });
3948        }
3949        self.slack_top
3950            .sort_by(|a, b| (b.drawn_px2 - b.lit_px2).total_cmp(&(a.drawn_px2 - a.lit_px2)));
3951        self.slack_top.truncate(FILL_DIAG_SLACK_TOP);
3952    }
3953
3954    /// Area of an image or text-image quad from its four device-space
3955    /// corners (TL, TR, BL, BR — the shared `(0, 1, 2)(2, 1, 3)` pattern).
3956    /// Textures light every pixel of their quad, so lit == submitted.
3957    fn add_image_quad(&self, quad: &[[f32; 2]; 4]) {
3958        let corner = |index: usize| [f64::from(quad[index][0]), f64::from(quad[index][1])];
3959        let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
3960            ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
3961        };
3962        let [a, b, c, d] = [corner(0), corner(1), corner(2), corner(3)];
3963        let area = tri(a, b, c) + tri(c, b, d);
3964        self.add(Self::IMAGE_GLYPH, area);
3965        self.add_lit(Self::IMAGE_GLYPH, area);
3966    }
3967
3968    /// One glyph atlas quad, axis-aligned by construction.
3969    fn add_glyph_quad(&self, quad: &CachedTextGlyphQuad) {
3970        let area = quad.width as f64 * quad.height as f64;
3971        self.add(Self::IMAGE_GLYPH, area);
3972        self.add_lit(Self::IMAGE_GLYPH, area);
3973    }
3974
3975    /// Effect-renderer pass fill drained once per frame from the effect
3976    /// renderer's own counters. Full-target draws: every counted pixel is
3977    /// shaded, so lit == submitted and slack is zero by construction.
3978    fn add_effect_fill(&self, composite_px2: f64, offscreen_px2: f64) {
3979        if composite_px2 > 0.0 {
3980            self.add(Self::EFFECT_COMPOSITE, composite_px2);
3981            self.add_lit(Self::EFFECT_COMPOSITE, composite_px2);
3982        }
3983        if offscreen_px2 > 0.0 {
3984            self.add(Self::OFFSCREEN_SOURCE, offscreen_px2);
3985            self.add_lit(Self::OFFSCREEN_SOURCE, offscreen_px2);
3986        }
3987    }
3988
3989    /// One render pass targeting an offscreen source texture (shadow source
3990    /// passes): the whole target area counts — its clear/load/store round
3991    /// trip — on top of the shape quads the pass draws, which
3992    /// [`Self::add_shape_quads`] prices separately under the pass's own
3993    /// bounds viewport.
3994    fn add_offscreen_target_fill(&self, px2: f64) {
3995        if px2 > 0.0 {
3996            self.add(Self::OFFSCREEN_SOURCE, px2);
3997            self.add_lit(Self::OFFSCREEN_SOURCE, px2);
3998        }
3999    }
4000
4001    /// Restarts the frame counters and latches the surface size — called
4002    /// from the same per-frame reset point as the transient rim mesh
4003    /// scratch.
4004    fn reset_frame(&self, width: u32, height: u32) {
4005        for cell in &self.frame {
4006            cell.set(0.0);
4007        }
4008        for cell in &self.frame_lit {
4009            cell.set(0.0);
4010        }
4011        for cell in &self.frame_opacity {
4012            cell.set(0.0);
4013        }
4014        self.frame_corner.set(0.0);
4015        self.viewport.set((width, height));
4016    }
4017
4018    /// Folds the frame into the window and, every
4019    /// [`FILL_DIAG_WINDOW_FRAMES`] rendered frames, emits the `[fill-diag]`
4020    /// bucket line, the `[fill-truth]` lit/slack + opacity + corner line,
4021    /// and — once per process — the retained top-slack dump.
4022    fn finish_frame(&mut self, width: u32, height: u32) {
4023        for (total, cell) in self.window.iter_mut().zip(&self.frame) {
4024            *total += cell.get();
4025        }
4026        for (total, cell) in self.window_lit.iter_mut().zip(&self.frame_lit) {
4027            *total += cell.get();
4028        }
4029        for (total, cell) in self.window_opacity.iter_mut().zip(&self.frame_opacity) {
4030            *total += cell.get();
4031        }
4032        self.window_corner += self.frame_corner.get();
4033        self.window_frames += 1;
4034        if self.window_frames < FILL_DIAG_WINDOW_FRAMES {
4035            return;
4036        }
4037        let frames = f64::from(self.window_frames);
4038        let mega = |bucket: usize| self.window[bucket] / frames / 1e6;
4039        let total_mega = self.window.iter().sum::<f64>() / frames / 1e6;
4040        let screen_mega = f64::from(width) * f64::from(height) / 1e6;
4041        let overdraw = if screen_mega > 0.0 {
4042            total_mega / screen_mega
4043        } else {
4044            0.0
4045        };
4046        log::warn!(
4047            "[fill-diag] Mpx/frame: arc {:.1}, rrect-stroke {:.1}, rrect-fill {:.1}, \
4048             rect {:.1}, mesh {:.1}, retained {:.1}, img+glyph {:.1}, \
4049             effect-comp {:.1}, offscr-src {:.1}, total {:.1} \
4050             ({:.1}x overdraw of {:.3} Mpx)",
4051            mega(Self::ARC),
4052            mega(Self::RRECT_STROKE),
4053            mega(Self::RRECT_FILL),
4054            mega(Self::RECT),
4055            mega(Self::MESH),
4056            mega(Self::RETAINED),
4057            mega(Self::IMAGE_GLYPH),
4058            mega(Self::EFFECT_COMPOSITE),
4059            mega(Self::OFFSCREEN_SOURCE),
4060            total_mega,
4061            overdraw,
4062            screen_mega,
4063        );
4064        // Lit vs slack per bucket: lit per [`analytic_lit_area`], slack the
4065        // remainder of the submitted area (clamped — negatives are rim-mesh
4066        // rounding, not information).
4067        let lit = |bucket: usize| self.window_lit[bucket] / frames / 1e6;
4068        let slack = |bucket: usize| (mega(bucket) - lit(bucket)).max(0.0);
4069        let truth = |bucket: usize| format!("{:.2}|{:.2}", lit(bucket), slack(bucket));
4070        log::warn!(
4071            "[fill-truth] Mpx/frame lit|slack: arc {}, rrect-stroke {}, rrect-fill {}, \
4072             rect {}, mesh {}, retained {}, img+glyph {}, effect-comp {}, offscr-src {}; \
4073             lit alpha Mpx: opaque {:.2}, translucent {:.2}, nonsolid {:.2}; \
4074             corner-outside {:.2}",
4075            truth(Self::ARC),
4076            truth(Self::RRECT_STROKE),
4077            truth(Self::RRECT_FILL),
4078            truth(Self::RECT),
4079            truth(Self::MESH),
4080            truth(Self::RETAINED),
4081            truth(Self::IMAGE_GLYPH),
4082            truth(Self::EFFECT_COMPOSITE),
4083            truth(Self::OFFSCREEN_SOURCE),
4084            self.window_opacity[FillOpacityClass::Opaque as usize] / frames / 1e6,
4085            self.window_opacity[FillOpacityClass::Translucent as usize] / frames / 1e6,
4086            self.window_opacity[FillOpacityClass::NonSolid as usize] / frames / 1e6,
4087            self.window_corner / frames / 1e6,
4088        );
4089        if !self.slack_dumped && !self.slack_top.is_empty() {
4090            log::warn!("[fill-truth] top retained slack (once per process, capture-space px):");
4091            for (rank, entry) in self.slack_top.iter().enumerate() {
4092                log::warn!(
4093                    "[fill-truth]   #{} slot {} shape {} {}: quad {:.0}, lit {:.0}, \
4094                     slack {:.0}",
4095                    rank + 1,
4096                    entry.slot,
4097                    entry.shape,
4098                    fill_diag_bucket_name(entry.bucket),
4099                    entry.drawn_px2,
4100                    entry.lit_px2,
4101                    entry.drawn_px2 - entry.lit_px2,
4102                );
4103            }
4104            self.slack_dumped = true;
4105            self.slack_top = Vec::new();
4106        }
4107        self.window = [0.0; FILL_DIAG_BUCKETS];
4108        self.window_lit = [0.0; FILL_DIAG_BUCKETS];
4109        self.window_opacity = [0.0; 3];
4110        self.window_corner = 0.0;
4111        self.window_frames = 0;
4112    }
4113}
4114
4115#[cfg(not(target_arch = "wasm32"))]
4116struct ArcMeshBuild {
4117    vertices: Vec<MeshVertex>,
4118    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
4119    indices: Vec<u32>,
4120    /// `shape_count + 1` entries; shape `i` owns triangles
4121    /// `indices[index_prefix[i]..index_prefix[i + 1]]`.
4122    index_prefix: Vec<u32>,
4123    meshed_arcs: usize,
4124    meshed_segments: usize,
4125    passthrough: usize,
4126    quad_area: f64,
4127    mesh_area: f64,
4128}
4129
4130/// Builds a slot's conservative indexed mesh: arc bands become
4131/// vertex-sharing trapezoid strips, every other shape a passthrough quad
4132/// (four vertices, six indices), in the exact capture shape order. Returns
4133/// `None` when the byte budget overflows — the caller warns and the whole
4134/// slot replays through the quad-expansion path (silent truncation would
4135/// break the containment invariant).
4136#[cfg(not(target_arch = "wasm32"))]
4137fn build_arc_mesh_vertices(shape_data: &[ShapeData]) -> Option<ArcMeshBuild> {
4138    let budget_bytes =
4139        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
4140    let mut build = ArcMeshBuild {
4141        vertices: Vec::new(),
4142        indices: Vec::new(),
4143        index_prefix: Vec::with_capacity(shape_data.len() + 1),
4144        meshed_arcs: 0,
4145        meshed_segments: 0,
4146        passthrough: 0,
4147        quad_area: 0.0,
4148        mesh_area: 0.0,
4149    };
4150    build.index_prefix.push(0);
4151    for (index, shape) in shape_data.iter().enumerate() {
4152        let start = build.indices.len();
4153        let meshed = arc_mesh_band(shape).and_then(|band| {
4154            emit_arc_band_mesh(
4155                shape,
4156                index as u32,
4157                &band,
4158                &mut build.vertices,
4159                &mut build.indices,
4160            )
4161        });
4162        match meshed {
4163            Some(segments) => {
4164                build.meshed_arcs += 1;
4165                build.meshed_segments += segments;
4166            }
4167            None => {
4168                emit_passthrough_quad(shape, index as u32, &mut build.vertices, &mut build.indices);
4169                build.passthrough += 1;
4170            }
4171        }
4172        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
4173            return None;
4174        }
4175        build.index_prefix.push(build.indices.len() as u32);
4176        build.quad_area += quad_shoelace_area(shape);
4177        build.mesh_area += triangles_shoelace_area(&build.vertices, &build.indices[start..]);
4178    }
4179    Some(build)
4180}
4181
4182/// The renderer's registry of live replay slots. The replay cache (scene
4183/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
4184#[cfg(not(target_arch = "wasm32"))]
4185struct ReplaySlotStore {
4186    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
4187    transform_buffer: wgpu::Buffer,
4188    free_ids: Vec<u32>,
4189    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
4190    /// on every capture, never reused, so an epoch identifies one capture's
4191    /// buffers for the renderer's whole lifetime.
4192    next_capture_epoch: u64,
4193}
4194
4195#[cfg(not(target_arch = "wasm32"))]
4196impl ReplaySlotStore {
4197    fn new(device: &wgpu::Device) -> Self {
4198        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4199            label: Some("Replay Transform Buffer"),
4200            size: MAX_REPLAY_SLOTS as u64 * REPLAY_TRANSFORM_STRIDE,
4201            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4202            mapped_at_creation: false,
4203        });
4204        Self {
4205            slots: std::collections::HashMap::default(),
4206            transform_buffer,
4207            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
4208            next_capture_epoch: 1,
4209        }
4210    }
4211}
4212
4213/// Kill switch for cached retained render bundles, mirroring
4214/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
4215/// `debug.cranpose.retained_bundles` property on Android) drops the fused
4216/// retained arms back to direct per-op encoding, so a device A/B needs no
4217/// rebuild. Read per partition — the parity harness flips it between passes.
4218#[cfg(not(target_arch = "wasm32"))]
4219fn retained_bundles_enabled() -> bool {
4220    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
4221}
4222
4223/// Kill switch for instanced ordinary-shape quads: default ON,
4224/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
4225/// property on Android) reverts every ordinary shape draw to the six-vertex
4226/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
4227/// ONCE per [`GpuRenderer`] construction into a field: cached retained
4228/// bundles encode the selected pipeline, so a flag that moved per draw would
4229/// let a cached bundle replay a selection the direct path no longer makes.
4230#[cfg(not(target_arch = "wasm32"))]
4231fn instanced_quads_enabled() -> bool {
4232    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
4233}
4234
4235/// The index pattern of one instanced quad: the exact triangle pair
4236/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
4237/// diagonal, same winding — shared by every instance.
4238#[cfg(not(target_arch = "wasm32"))]
4239const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
4240
4241/// The latched instanced-quad selection: `Some` exactly when the renderer
4242/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
4243/// blend variants exist because ordinary batches draw SrcOver and DstOut;
4244/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
4245/// uniform-mode path) still has its six-vertex draws.
4246#[cfg(not(target_arch = "wasm32"))]
4247struct InstancedQuadPipelines {
4248    pipeline: LazyGpuResource<wgpu::RenderPipeline>,
4249    pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
4250    /// `fs_solid` twin of `pipeline` (SrcOver only): chosen for draws whose
4251    /// shapes carry no gradient stops, which is nearly every draw of an
4252    /// arc-heavy scene.
4253    pipeline_solid: LazyGpuResource<wgpu::RenderPipeline>,
4254    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
4255    /// by every instanced draw.
4256    index_buffer: wgpu::Buffer,
4257}
4258
4259/// Everything that decides the commands one retained op contributes to a
4260/// cached bundle. Equal op keys imply identical encoded commands:
4261/// `capture_epoch` pins the slot's bind group and buffers to one capture,
4262/// `has_mesh` pins the pipeline and vertex-buffer choice, `first..last` is
4263/// the clamped draw range, and `retained_index` is the dynamic transform
4264/// offset. Transforms and paints are NOT here — they are data-buffer
4265/// contents the bundle reads at execution.
4266#[cfg(not(target_arch = "wasm32"))]
4267#[derive(Clone, Debug, PartialEq, Eq, Hash)]
4268struct RetainedBundleOpKey {
4269    slot: u32,
4270    /// The slot's capture epoch at key time, `None` while the slot is absent
4271    /// from the store (the op encodes nothing). Epochs are globally unique
4272    /// per capture, so a recaptured slot reusing its id can never satisfy a
4273    /// key recorded against the previous capture's buffers.
4274    capture_epoch: Option<u64>,
4275    first: u32,
4276    last: u32,
4277    retained_index: u32,
4278    has_mesh: bool,
4279}
4280
4281/// Key of one maximal consecutive retained stretch: the op keys in draw
4282/// order. Any reorder, count change, range change, recapture, or slot
4283/// release changes the key and forces a rebuild.
4284#[cfg(not(target_arch = "wasm32"))]
4285#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
4286struct RetainedBundleKey {
4287    ops: Vec<RetainedBundleOpKey>,
4288}
4289
4290#[cfg(not(target_arch = "wasm32"))]
4291struct RetainedBundleCacheEntry<B> {
4292    bundle: B,
4293    last_used_frame: u64,
4294}
4295
4296/// Cache of encoded render bundles for retained stretches, generic over the
4297/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
4298/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
4299/// key can only ever build a fresh bundle, never alias a stale one.
4300///
4301/// The surface format and the group-0 uniform bind group are deliberately
4302/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
4303/// surface reconfigure builds a new renderer, and with it an empty cache).
4304#[cfg(not(target_arch = "wasm32"))]
4305struct RetainedBundleCacheImpl<B> {
4306    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
4307    frame: u64,
4308    rebuilds: u64,
4309    cached_executes: u64,
4310    window_rebuilds: u64,
4311    window_executes: u64,
4312}
4313
4314#[cfg(not(target_arch = "wasm32"))]
4315type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
4316
4317#[cfg(not(target_arch = "wasm32"))]
4318impl<B> RetainedBundleCacheImpl<B> {
4319    fn new() -> Self {
4320        Self {
4321            entries: HashMap::default(),
4322            frame: 0,
4323            rebuilds: 0,
4324            cached_executes: 0,
4325            window_rebuilds: 0,
4326            window_executes: 0,
4327        }
4328    }
4329
4330    /// True when a bundle for `key` is cached; marks it used this frame and
4331    /// counts a cached execute.
4332    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
4333        let frame = self.frame;
4334        match self.entries.get_mut(key) {
4335            Some(entry) => {
4336                entry.last_used_frame = frame;
4337                self.cached_executes += 1;
4338                self.window_executes += 1;
4339                true
4340            }
4341            None => false,
4342        }
4343    }
4344
4345    /// Stores a freshly built bundle, counting a rebuild.
4346    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
4347        self.rebuilds += 1;
4348        self.window_rebuilds += 1;
4349        self.entries.insert(
4350            key,
4351            RetainedBundleCacheEntry {
4352                bundle,
4353                last_used_frame: self.frame,
4354            },
4355        );
4356    }
4357
4358    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
4359        self.entries.get(key).map(|entry| &entry.bundle)
4360    }
4361
4362    /// Drops every cached bundle. Called whenever a replay slot is released:
4363    /// the key compare already makes stale entries unreachable (their epochs
4364    /// can never recur), so this only releases the dropped capture's GPU
4365    /// resources promptly instead of one frame later via eviction.
4366    fn clear(&mut self) {
4367        self.entries.clear();
4368    }
4369
4370    /// Frame boundary: evicts entries the frame did not use — a bundle
4371    /// holds references on its slot's buffers, so unused entries must not
4372    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
4373    fn end_frame(&mut self) {
4374        let frame = self.frame;
4375        self.entries
4376            .retain(|_, entry| entry.last_used_frame >= frame);
4377        self.frame = self.frame.wrapping_add(1);
4378        // Always-on at a cadence that cannot spam; every perf window (120
4379        // frames) under the replay diagnostics flag so short A/B runs see
4380        // the counts. log::warn because log::info is invisible on the
4381        // desktop console.
4382        let due = self.frame.is_multiple_of(1024)
4383            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
4384                && self.frame.is_multiple_of(120));
4385        if due && self.window_rebuilds + self.window_executes > 0 {
4386            log::warn!(
4387                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
4388                self.window_rebuilds + self.window_executes,
4389                self.window_rebuilds,
4390                self.window_executes,
4391                self.entries.len(),
4392            );
4393            self.window_rebuilds = 0;
4394            self.window_executes = 0;
4395        }
4396    }
4397
4398    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
4399    fn stats(&self) -> (u64, u64) {
4400        (self.rebuilds, self.cached_executes)
4401    }
4402}
4403
4404struct CachedImageTexture {
4405    _texture: wgpu::Texture,
4406    _view: wgpu::TextureView,
4407    nearest_bind_group: wgpu::BindGroup,
4408    linear_bind_group: wgpu::BindGroup,
4409    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
4410    /// well as count. A live camera publishes a new multi-MB bitmap id every
4411    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
4412    /// which on iOS unified memory counts straight against the process's
4413    /// jetsam limit (measured: the app died mid-scan under an open camera
4414    /// with exactly that ballast).
4415    bytes: usize,
4416}
4417
4418impl CachedImageTexture {
4419    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
4420        match sampling {
4421            ImageSampling::Nearest => &self.nearest_bind_group,
4422            ImageSampling::Linear => &self.linear_bind_group,
4423        }
4424    }
4425}
4426
4427#[derive(Clone, Copy)]
4428struct GlyphAtlasEntry {
4429    x: u32,
4430    y: u32,
4431    width: u32,
4432    height: u32,
4433}
4434
4435/// Side length the glyph atlas should be rebuilt at after it overflowed at
4436/// `current`: one doubling, never past `max`.
4437///
4438/// Doubling (rather than jumping straight to `max`) is what makes the atlas
4439/// cost track the workload: an app that overflows once needs a little more
4440/// room, not sixteen times more.
4441fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
4442    current.saturating_mul(2).clamp(1, max.max(1))
4443}
4444
4445struct TextGlyphAtlas {
4446    texture: wgpu::Texture,
4447    _view: wgpu::TextureView,
4448    bind_group: wgpu::BindGroup,
4449    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
4450    generation: u64,
4451    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
4452    /// device's ceiling. Every UV is normalised against it, so it has to travel
4453    /// with the atlas rather than be read back off a constant.
4454    size: u32,
4455    /// Largest side length this atlas may grow to: the smaller of
4456    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
4457    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
4458    /// so a device that only offers 2048 would otherwise fail to create the
4459    /// texture outright.
4460    max_size: u32,
4461    cursor_x: u32,
4462    cursor_y: u32,
4463    row_height: u32,
4464    upload_scratch: Vec<u8>,
4465}
4466
4467impl TextGlyphAtlas {
4468    fn new(
4469        device: &wgpu::Device,
4470        image_layout: &wgpu::BindGroupLayout,
4471        sampler: &wgpu::Sampler,
4472        size: u32,
4473    ) -> Self {
4474        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
4475        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
4476        let texture = Self::create_texture(device, size);
4477        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
4478        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
4479            label: Some("Text Glyph Atlas Bind Group"),
4480            layout: image_layout,
4481            entries: &[
4482                wgpu::BindGroupEntry {
4483                    binding: 0,
4484                    resource: wgpu::BindingResource::TextureView(&view),
4485                },
4486                wgpu::BindGroupEntry {
4487                    binding: 1,
4488                    resource: wgpu::BindingResource::Sampler(sampler),
4489                },
4490            ],
4491        });
4492        Self {
4493            texture,
4494            _view: view,
4495            bind_group,
4496            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
4497            generation: 0,
4498            size,
4499            max_size,
4500            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
4501            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
4502            row_height: 0,
4503            upload_scratch: Vec::new(),
4504        }
4505    }
4506
4507    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
4508        device.create_texture(&wgpu::TextureDescriptor {
4509            label: Some("Text Glyph Atlas Texture"),
4510            size: wgpu::Extent3d {
4511                width: size,
4512                height: size,
4513                depth_or_array_layers: 1,
4514            },
4515            mip_level_count: 1,
4516            sample_count: 1,
4517            dimension: wgpu::TextureDimension::D2,
4518            format: wgpu::TextureFormat::R8Unorm,
4519            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4520            view_formats: &[],
4521        })
4522    }
4523
4524    /// Throws every cached glyph away and starts over on a texture one doubling
4525    /// larger, up to [`TextGlyphAtlas::max_size`].
4526    ///
4527    /// `allocate` is a one-way shelf cursor with no compaction, so the only
4528    /// recovery from a full atlas is to start again — and starting again at the
4529    /// same size makes a workload whose live glyph set genuinely does not fit
4530    /// re-raster every glyph every frame. Treating each overflow as the signal
4531    /// to double means the atlas converges on the size the workload actually
4532    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
4533    /// resets and behaves identically from then on, while a watch face that
4534    /// never overflows never pays for space it will not use.
4535    ///
4536    /// Bumping the generation is what invalidates the cached glyph runs, whose
4537    /// UVs are normalised against the previous size and would otherwise sample
4538    /// the wrong part of the new texture.
4539    fn reset(
4540        &mut self,
4541        device: &wgpu::Device,
4542        image_layout: &wgpu::BindGroupLayout,
4543        sampler: &wgpu::Sampler,
4544    ) {
4545        let generation = self.generation.wrapping_add(1);
4546        let grown = next_glyph_atlas_size(self.size, self.max_size);
4547        let mut next = Self::new(device, image_layout, sampler, grown);
4548        next.generation = generation;
4549        *self = next;
4550    }
4551
4552    fn generation(&self) -> u64 {
4553        self.generation
4554    }
4555
4556    fn size(&self) -> u32 {
4557        self.size
4558    }
4559
4560    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
4561        self.entries.get(key).copied()
4562    }
4563
4564    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
4565        if width == 0
4566            || height == 0
4567            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
4568            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
4569        {
4570            return None;
4571        }
4572
4573        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
4574            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
4575            self.cursor_y = self
4576                .cursor_y
4577                .saturating_add(self.row_height)
4578                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
4579            self.row_height = 0;
4580        }
4581        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
4582            return None;
4583        }
4584
4585        let entry = GlyphAtlasEntry {
4586            x: self.cursor_x,
4587            y: self.cursor_y,
4588            width,
4589            height,
4590        };
4591        self.cursor_x = self
4592            .cursor_x
4593            .saturating_add(width)
4594            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
4595        self.row_height = self.row_height.max(height);
4596        Some(entry)
4597    }
4598
4599    fn upload_glyph(
4600        &mut self,
4601        key: SoftwareGlyphAtlasKey,
4602        glyph: &SoftwareGlyphAtlasGlyph,
4603        queue: &wgpu::Queue,
4604        executor: &mut WgpuFrameGraphExecutor,
4605        frame_stats: &mut gpu_stats::FrameStats,
4606    ) -> Option<GlyphAtlasEntry> {
4607        if let Some(entry) = self.entry(&key) {
4608            frame_stats.record_text_glyph_atlas_hit();
4609            return Some(entry);
4610        }
4611
4612        let width = u32::try_from(glyph.mask.width).ok()?;
4613        let height = u32::try_from(glyph.mask.height).ok()?;
4614        let entry = self.allocate(width, height)?;
4615        self.upload_scratch.clear();
4616        self.upload_scratch.reserve(
4617            glyph
4618                .mask
4619                .alpha
4620                .len()
4621                .saturating_sub(self.upload_scratch.capacity()),
4622        );
4623        self.upload_scratch.extend(
4624            glyph
4625                .mask
4626                .alpha
4627                .iter()
4628                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
4629        );
4630
4631        let upload_stats = executor.upload_texture(
4632            queue,
4633            wgpu::TexelCopyTextureInfo {
4634                texture: &self.texture,
4635                mip_level: 0,
4636                origin: wgpu::Origin3d {
4637                    x: entry.x,
4638                    y: entry.y,
4639                    z: 0,
4640                },
4641                aspect: wgpu::TextureAspect::All,
4642            },
4643            &self.upload_scratch,
4644            wgpu::TexelCopyBufferLayout {
4645                offset: 0,
4646                bytes_per_row: Some(entry.width),
4647                rows_per_image: Some(entry.height),
4648            },
4649            wgpu::Extent3d {
4650                width: entry.width,
4651                height: entry.height,
4652                depth_or_array_layers: 1,
4653            },
4654        );
4655        frame_stats.record_command_stats(upload_stats);
4656        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
4657        self.entries.put(key, entry);
4658        Some(entry)
4659    }
4660}
4661
4662struct ImageDrawCmd {
4663    index_start: u32,
4664    scissor: (u32, u32, u32, u32),
4665    image_id: u64,
4666    sampling: ImageSampling,
4667}
4668
4669#[derive(Clone, Copy)]
4670enum GlyphDrawSource {
4671    Shared {
4672        index_start: u32,
4673        index_count: u32,
4674    },
4675    #[cfg(not(target_arch = "wasm32"))]
4676    Retained {
4677        cache_key: TextGlyphRunCacheKey,
4678        uniform_slot: usize,
4679    },
4680}
4681
4682#[derive(Clone, Copy)]
4683struct GlyphDrawCmd {
4684    source: GlyphDrawSource,
4685    scissor: (u32, u32, u32, u32),
4686}
4687
4688impl GlyphDrawCmd {
4689    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
4690        Self {
4691            source: GlyphDrawSource::Shared {
4692                index_start,
4693                index_count,
4694            },
4695            scissor,
4696        }
4697    }
4698
4699    #[cfg(not(target_arch = "wasm32"))]
4700    fn retained(
4701        cache_key: TextGlyphRunCacheKey,
4702        uniform_slot: usize,
4703        scissor: (u32, u32, u32, u32),
4704    ) -> Self {
4705        Self {
4706            source: GlyphDrawSource::Retained {
4707                cache_key,
4708                uniform_slot,
4709            },
4710            scissor,
4711        }
4712    }
4713}
4714
4715#[derive(Clone, Copy, Debug, PartialEq)]
4716struct ImageUvRect {
4717    min: [f32; 2],
4718    max: [f32; 2],
4719    sample_bounds: [f32; 4],
4720}
4721
4722// Text raster cache is owned by GpuRenderer and backed by software text images
4723// between measurement and rendering to eliminate duplicate text shaping
4724
4725/// Persistent GPU buffers for batched shape rendering. There is no vertex or
4726/// index buffer: the shape shader pulls quad corners straight out of
4727/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
4728struct ShapeBatchBuffers {
4729    shape_buffer: wgpu::Buffer,
4730    gradient_buffer: wgpu::Buffer,
4731    bind_group: wgpu::BindGroup,
4732    shape_capacity: usize,
4733    gradient_capacity: usize,
4734    batch_limits: ShapeBatchLimits,
4735}
4736
4737#[cfg(target_arch = "wasm32")]
4738struct UniformBatchBuffer {
4739    buffer: wgpu::Buffer,
4740    bind_group: wgpu::BindGroup,
4741}
4742
4743#[cfg(target_arch = "wasm32")]
4744struct ImageBatchBuffers {
4745    vertex_buffer: wgpu::Buffer,
4746    index_buffer: wgpu::Buffer,
4747    vertex_capacity: usize,
4748    index_capacity: usize,
4749}
4750
4751#[derive(Clone, Copy, Debug, PartialEq)]
4752struct ViewportUniformParams {
4753    width: u32,
4754    height: u32,
4755    offset: [f32; 2],
4756}
4757
4758#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4759#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
4760enum UploadTarget {
4761    Uniform,
4762    ShapeData,
4763    ShapeGradient,
4764    ImageVertex,
4765    ImageIndex,
4766    #[cfg(not(target_arch = "wasm32"))]
4767    RetainedGlyphUniform,
4768    /// The shared replay-transform buffer; copies land at each slot's fixed
4769    /// 256-byte-aligned offset.
4770    #[cfg(not(target_arch = "wasm32"))]
4771    ReplayTransform,
4772    /// A replay slot's retained paint buffer (color patches land here).
4773    #[cfg(not(target_arch = "wasm32"))]
4774    ReplayPaintData(u32),
4775}
4776
4777#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4778#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
4779struct PendingBufferCopy {
4780    source_offset: u64,
4781    target_offset: u64,
4782    size: u64,
4783    target: UploadTarget,
4784}
4785
4786#[derive(Default)]
4787struct StagedBufferUploads {
4788    bytes: Vec<u8>,
4789    copies: Vec<PendingBufferCopy>,
4790}
4791
4792impl StagedBufferUploads {
4793    fn clear(&mut self) {
4794        self.bytes.clear();
4795        self.copies.clear();
4796    }
4797
4798    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
4799        let mut shrunk = false;
4800        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
4801            self.bytes.shrink_to(max_bytes);
4802            shrunk = true;
4803        }
4804        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
4805            self.copies.shrink_to(max_copies);
4806            shrunk = true;
4807        }
4808        shrunk
4809    }
4810
4811    fn is_empty(&self) -> bool {
4812        self.copies.is_empty()
4813    }
4814
4815    #[cfg(test)]
4816    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
4817        let start = copy.source_offset as usize;
4818        let end = start + copy.size as usize;
4819        &self.bytes[start..end]
4820    }
4821
4822    #[cfg(not(target_arch = "wasm32"))]
4823    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
4824        self.stage_at(target, 0, bytes);
4825    }
4826
4827    /// Records a GPU copy whose source bytes were already written into the
4828    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
4829    /// appended to `bytes`. `source_offset` is relative to the same base the
4830    /// caller later passes to `flush_staged_uploads_at`.
4831    #[cfg(not(target_arch = "wasm32"))]
4832    fn record_upload_copy(
4833        &mut self,
4834        target: UploadTarget,
4835        source_offset: u64,
4836        target_offset: u64,
4837        size: u64,
4838    ) {
4839        if size == 0 {
4840            return;
4841        }
4842        self.copies.push(PendingBufferCopy {
4843            source_offset,
4844            target_offset,
4845            size,
4846            target,
4847        });
4848    }
4849
4850    #[cfg(not(target_arch = "wasm32"))]
4851    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
4852        if bytes.is_empty() {
4853            return;
4854        }
4855
4856        debug_assert_eq!(
4857            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
4858            0,
4859            "buffer uploads must be aligned to copy requirements"
4860        );
4861
4862        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
4863        if aligned_offset > self.bytes.len() {
4864            self.bytes.resize(aligned_offset, 0);
4865        }
4866
4867        let source_offset = self.bytes.len() as u64;
4868        self.bytes.extend_from_slice(bytes);
4869        self.copies.push(PendingBufferCopy {
4870            source_offset,
4871            target_offset,
4872            size: bytes.len() as u64,
4873            target,
4874        });
4875    }
4876
4877    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
4878        self.bytes.truncate(bytes_len);
4879        self.copies.truncate(copies_len);
4880    }
4881}
4882
4883/// The fresh-batch entry list for the shape bind group layout: the batch's
4884/// own data buffers, the shared identity similarity buffer, and — storage
4885/// mode only, where the layout carries the paint entry — the renderer-wide
4886/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
4887fn shape_batch_bind_group_entries<'a>(
4888    shape_buffer: &'a wgpu::Buffer,
4889    gradient_buffer: &'a wgpu::Buffer,
4890    similarity_buffer: &'a wgpu::Buffer,
4891    paint_buffer: Option<&'a wgpu::Buffer>,
4892) -> Vec<wgpu::BindGroupEntry<'a>> {
4893    let mut entries = vec![
4894        wgpu::BindGroupEntry {
4895            binding: 0,
4896            resource: shape_buffer.as_entire_binding(),
4897        },
4898        wgpu::BindGroupEntry {
4899            binding: 1,
4900            resource: gradient_buffer.as_entire_binding(),
4901        },
4902        wgpu::BindGroupEntry {
4903            binding: 2,
4904            resource: similarity_buffer.as_entire_binding(),
4905        },
4906    ];
4907    if let Some(paint_buffer) = paint_buffer {
4908        entries.push(wgpu::BindGroupEntry {
4909            binding: 3,
4910            resource: paint_buffer.as_entire_binding(),
4911        });
4912    }
4913    entries
4914}
4915
4916impl ShapeBatchBuffers {
4917    fn new(
4918        device: &wgpu::Device,
4919        bind_group_layout: &wgpu::BindGroupLayout,
4920        similarity_buffer: &wgpu::Buffer,
4921        paint_buffer: Option<&wgpu::Buffer>,
4922        batch_limits: ShapeBatchLimits,
4923    ) -> Self {
4924        debug_assert_eq!(
4925            paint_buffer.is_some(),
4926            batch_limits.storage,
4927            "the paint binding exists exactly when the layout is in storage mode"
4928        );
4929        let initial_shape_cap = batch_limits.initial_shape_capacity();
4930        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
4931
4932        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4933            label: Some("Shape Data Buffer"),
4934            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
4935            usage: batch_limits.data_buffer_usage(),
4936            mapped_at_creation: false,
4937        });
4938
4939        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4940            label: Some("Gradient Buffer"),
4941            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
4942            usage: batch_limits.data_buffer_usage(),
4943            mapped_at_creation: false,
4944        });
4945
4946        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
4947            label: Some("Shape Bind Group"),
4948            layout: bind_group_layout,
4949            entries: &shape_batch_bind_group_entries(
4950                &shape_buffer,
4951                &gradient_buffer,
4952                similarity_buffer,
4953                paint_buffer,
4954            ),
4955        });
4956
4957        Self {
4958            shape_buffer,
4959            gradient_buffer,
4960            bind_group,
4961            shape_capacity: initial_shape_cap,
4962            gradient_capacity: initial_gradient_cap,
4963            batch_limits,
4964        }
4965    }
4966
4967    /// Ensure buffers have enough capacity, resizing if needed.
4968    /// Clamps growth to prevent excessive allocations for huge scenes.
4969    fn ensure_capacity(
4970        &mut self,
4971        device: &wgpu::Device,
4972        bind_group_layout: &wgpu::BindGroupLayout,
4973        similarity_buffer: &wgpu::Buffer,
4974        paint_buffer: Option<&wgpu::Buffer>,
4975        shapes_needed: usize,
4976        gradients_needed: usize,
4977    ) {
4978        let mut need_bind_group_update = false;
4979
4980        // In uniform mode the shape and gradient buffers start at the cap
4981        // (the shader's fixed-size array length) so these never fire; in
4982        // storage mode they double toward the cap as scenes demand.
4983        if shapes_needed > self.shape_capacity
4984            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
4985        {
4986            let new_cap = shapes_needed
4987                .next_power_of_two()
4988                .min(self.batch_limits.max_shapes_per_batch);
4989            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4990                label: Some("Shape Data Buffer"),
4991                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
4992                usage: self.batch_limits.data_buffer_usage(),
4993                mapped_at_creation: false,
4994            });
4995            self.shape_capacity = new_cap;
4996            need_bind_group_update = true;
4997        }
4998
4999        if gradients_needed > self.gradient_capacity
5000            && self.gradient_capacity < self.batch_limits.max_gradient_stops
5001        {
5002            let new_cap = gradients_needed
5003                .max(1)
5004                .next_power_of_two()
5005                .min(self.batch_limits.max_gradient_stops);
5006            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5007                label: Some("Gradient Buffer"),
5008                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
5009                usage: self.batch_limits.data_buffer_usage(),
5010                mapped_at_creation: false,
5011            });
5012            self.gradient_capacity = new_cap;
5013            need_bind_group_update = true;
5014        }
5015
5016        if need_bind_group_update {
5017            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5018                label: Some("Shape Bind Group"),
5019                layout: bind_group_layout,
5020                entries: &shape_batch_bind_group_entries(
5021                    &self.shape_buffer,
5022                    &self.gradient_buffer,
5023                    similarity_buffer,
5024                    paint_buffer,
5025                ),
5026            });
5027        }
5028    }
5029}
5030
5031#[cfg(target_arch = "wasm32")]
5032impl UniformBatchBuffer {
5033    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
5034        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
5035            label: Some("Viewport Uniform Batch Buffer"),
5036            size: std::mem::size_of::<Uniforms>() as u64,
5037            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5038            mapped_at_creation: false,
5039        });
5040        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5041            label: Some("Viewport Uniform Batch Bind Group"),
5042            layout: bind_group_layout,
5043            entries: &[wgpu::BindGroupEntry {
5044                binding: 0,
5045                resource: buffer.as_entire_binding(),
5046            }],
5047        });
5048        Self { buffer, bind_group }
5049    }
5050}
5051
5052#[cfg(target_arch = "wasm32")]
5053impl ImageBatchBuffers {
5054    fn new(device: &wgpu::Device) -> Self {
5055        let vertex_capacity = 4;
5056        let index_capacity = 6;
5057        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5058            label: Some("Image Vertex Batch Buffer"),
5059            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
5060            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5061            mapped_at_creation: false,
5062        });
5063        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5064            label: Some("Image Index Batch Buffer"),
5065            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
5066            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5067            mapped_at_creation: false,
5068        });
5069        Self {
5070            vertex_buffer,
5071            index_buffer,
5072            vertex_capacity,
5073            index_capacity,
5074        }
5075    }
5076
5077    fn ensure_capacity(
5078        &mut self,
5079        device: &wgpu::Device,
5080        vertices_needed: usize,
5081        indices_needed: usize,
5082    ) {
5083        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
5084        if vertices_needed > self.vertex_capacity {
5085            let desired = vertices_needed.next_power_of_two();
5086            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
5087            let new_cap = desired.min(max_count);
5088            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5089                label: Some("Image Vertex Batch Buffer"),
5090                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
5091                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5092                mapped_at_creation: false,
5093            });
5094            self.vertex_capacity = new_cap;
5095        }
5096        if indices_needed > self.index_capacity {
5097            let desired = indices_needed.next_power_of_two();
5098            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
5099            let new_cap = desired.min(max_count);
5100            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5101                label: Some("Image Index Batch Buffer"),
5102                size: (std::mem::size_of::<u32>() * new_cap) as u64,
5103                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5104                mapped_at_creation: false,
5105            });
5106            self.index_capacity = new_cap;
5107        }
5108    }
5109}
5110
5111// Text image cache keys are local to rasterized WGPU text batches
5112
5113pub struct GpuRenderer {
5114    pub(crate) device: Arc<wgpu::Device>,
5115    pub(crate) queue: Arc<wgpu::Queue>,
5116    /// This instance's renderer epoch, stamped by `init_gpu` at
5117    /// construction. A packet whose `renderer_epoch` differs was built
5118    /// against another instance and is cancelled at the head of
5119    /// [`Self::render`], never drawn.
5120    renderer_epoch: u64,
5121    /// The producer feed generation this store's slot universe belongs to:
5122    /// seeded at construction, advanced by `consume_replay_ops` when a
5123    /// higher-generation batch arrives (the batch itself carries the
5124    /// retirement releases). The store never reads the producer's
5125    /// thread-local — this field is its only generation authority.
5126    #[cfg(not(target_arch = "wasm32"))]
5127    store_feed_generation: u64,
5128    surface_format: wgpu::TextureFormat,
5129    adapter_backend: wgpu::Backend,
5130    shape_batch_limits: ShapeBatchLimits,
5131    pipeline: LazyGpuResource<wgpu::RenderPipeline>,
5132    pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
5133    /// `fs_solid` twin of `pipeline` (SrcOver only), for gradient-free draws.
5134    pipeline_solid: LazyGpuResource<wgpu::RenderPipeline>,
5135    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
5136    /// over a vertex buffer) that replay slots with a captured arc mesh draw
5137    /// through. Uniform-mode devices never host retained slots.
5138    #[cfg(not(target_arch = "wasm32"))]
5139    mesh_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
5140    /// `Some` exactly when this renderer latched the instanced-quad path at
5141    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
5142    /// ONCE per renderer lifetime — cached retained bundles encode the
5143    /// selection, so it must never move under them (see
5144    /// [`instanced_quads_enabled`]).
5145    #[cfg(not(target_arch = "wasm32"))]
5146    instanced_quads: Option<InstancedQuadPipelines>,
5147    uniform_bind_group_layout: wgpu::BindGroupLayout,
5148    shape_bind_group_layout: wgpu::BindGroupLayout,
5149    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
5150    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
5151    dummy_paint_buffer: Option<wgpu::Buffer>,
5152    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
5153    /// converted shape batch draws untransformed through this one buffer.
5154    identity_similarity_buffer: wgpu::Buffer,
5155    #[cfg(not(target_arch = "wasm32"))]
5156    replay_slots: ReplaySlotStore,
5157    image_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
5158    image_pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
5159    glyph_atlas_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
5160    #[cfg(not(target_arch = "wasm32"))]
5161    retained_glyph_atlas_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
5162    image_bind_group_layout: wgpu::BindGroupLayout,
5163    #[cfg(not(target_arch = "wasm32"))]
5164    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
5165    image_nearest_sampler: wgpu::Sampler,
5166    image_linear_sampler: wgpu::Sampler,
5167    text_fonts: SoftwareTextFontSet,
5168    // Persistent GPU buffers (reused across frames)
5169    #[cfg(not(target_arch = "wasm32"))]
5170    upload_buffer: wgpu::Buffer,
5171    #[cfg(not(target_arch = "wasm32"))]
5172    uniform_buffer: wgpu::Buffer,
5173    #[cfg(not(target_arch = "wasm32"))]
5174    uniform_bind_group: wgpu::BindGroup,
5175    #[cfg(not(target_arch = "wasm32"))]
5176    shape_buffers: ShapeBatchBuffers,
5177    #[cfg(not(target_arch = "wasm32"))]
5178    image_vertex_buffer: wgpu::Buffer,
5179    #[cfg(not(target_arch = "wasm32"))]
5180    image_index_buffer: wgpu::Buffer,
5181    #[cfg(not(target_arch = "wasm32"))]
5182    retained_glyph_uniform_buffer: wgpu::Buffer,
5183    #[cfg(not(target_arch = "wasm32"))]
5184    retained_glyph_uniform_bind_group: wgpu::BindGroup,
5185    #[cfg(not(target_arch = "wasm32"))]
5186    retained_glyph_uniform_stride: u64,
5187    #[cfg(not(target_arch = "wasm32"))]
5188    retained_glyph_uniform_capacity: usize,
5189    #[cfg(not(target_arch = "wasm32"))]
5190    retained_glyph_uniform_cursor: usize,
5191    #[cfg(target_arch = "wasm32")]
5192    wasm_uniform_batches: Vec<UniformBatchBuffer>,
5193    #[cfg(target_arch = "wasm32")]
5194    wasm_uniform_batch_cursor: usize,
5195    #[cfg(target_arch = "wasm32")]
5196    wasm_shape_batches: Vec<ShapeBatchBuffers>,
5197    #[cfg(target_arch = "wasm32")]
5198    wasm_shape_batch_cursor: usize,
5199    #[cfg(target_arch = "wasm32")]
5200    wasm_image_batches: Vec<ImageBatchBuffers>,
5201    #[cfg(target_arch = "wasm32")]
5202    wasm_image_batch_cursor: usize,
5203    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
5204    /// Total `CachedImageTexture::bytes` currently in the cache.
5205    image_texture_cache_bytes: usize,
5206    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
5207    text_glyph_atlas: TextGlyphAtlas,
5208    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
5209    #[cfg(not(target_arch = "wasm32"))]
5210    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
5211    text_glyph_mask_cache: SoftwareGlyphRasterCache,
5212    text_line_index_cache: TextLineIndexCache,
5213    scratch_shape_data: Vec<ShapeData>,
5214    scratch_gradients: Vec<GradientStop>,
5215    scratch_image_vertices: Vec<Vertex>,
5216    scratch_image_indices: Vec<u32>,
5217    scratch_image_cmds: Vec<ImageDrawCmd>,
5218    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
5219    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
5220    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
5221    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
5222    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
5223    scratch_effect_ranges: Vec<Range<usize>>,
5224    scratch_layer_events: Vec<LayerEvent>,
5225    staged_uploads: StagedBufferUploads,
5226    frame_graph_executor: WgpuFrameGraphExecutor,
5227    deferred_offscreen_releases: Vec<OffscreenTarget>,
5228    effect_renderer: EffectRenderer,
5229    layer_surface_cache: LayerSurfaceCache,
5230    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
5231    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
5232    shadow_surface_cache_bytes: u64,
5233    frame_stats: gpu_stats::FrameStats,
5234    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
5235    pending_frame_warmup_frames: u8,
5236    frame_count: u64,
5237    gpu_stats_enabled: bool,
5238    warning_state: RendererWarningState,
5239    #[cfg(not(target_arch = "wasm32"))]
5240    replay_upload_stats: ReplayUploadStats,
5241    /// The frame's replay recolor patches, parked here by
5242    /// `consume_replay_ops` until the retained prepare arms drain them
5243    /// (`stage_replay_patches`). The vec this frame's ops displace is last
5244    /// frame's, already drained empty, and returns to the producer with
5245    /// the ack — capacity ping-pongs planner queue → packet ops → here →
5246    /// ack return, so neither side allocates per frame (P4b).
5247    #[cfg(not(target_arch = "wasm32"))]
5248    replay_color_patches: Vec<crate::scene::ColorPatch>,
5249    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
5250    /// swaps against this instead of `mem::take`, so both keep their
5251    /// high-water capacity across frames. Always empty between drains.
5252    #[cfg(not(target_arch = "wasm32"))]
5253    color_patch_scratch: Vec<crate::scene::ColorPatch>,
5254    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
5255    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
5256    /// and the render loop hands the emptied vec (capacity intact) back
5257    /// here — the ack channel's half of the P4b no-allocation contract.
5258    #[cfg(not(target_arch = "wasm32"))]
5259    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
5260    /// Lifetime count of replay-ops batches dropped whole by the
5261    /// generation check in `consume_replay_ops` — fail-closed against ops
5262    /// planned under a slot universe this store no longer holds.
5263    /// Synchronously impossible today; structural for the pipeline split.
5264    #[cfg(not(target_arch = "wasm32"))]
5265    replay_generation_drops: u64,
5266    /// Cached render bundles for maximal consecutive retained stretches in
5267    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
5268    #[cfg(not(target_arch = "wasm32"))]
5269    retained_bundle_cache: RetainedBundleCache,
5270    /// Per-frame scratch for transient rim band meshes (`rim_mesh_band`):
5271    /// appended per fused chunk, cleared at the top of every frame. Index
5272    /// values are absolute into the frame's vertex list, so later chunks
5273    /// append without rebasing.
5274    #[cfg(not(target_arch = "wasm32"))]
5275    rim_mesh_vertices: Vec<MeshVertex>,
5276    #[cfg(not(target_arch = "wasm32"))]
5277    rim_mesh_indices: Vec<u32>,
5278    /// Fixed-capacity GPU twins of the rim scratch vecs, created lazily on
5279    /// the first rim ([`RIM_MESH_VERTEX_CAPACITY`] /
5280    /// [`RIM_MESH_INDEX_CAPACITY`]). NEVER recreated mid-frame: draws are
5281    /// encoded before submit, so a replacement buffer would orphan every
5282    /// already-encoded rim draw.
5283    #[cfg(not(target_arch = "wasm32"))]
5284    rim_mesh_vertex_buffer: Option<wgpu::Buffer>,
5285    #[cfg(not(target_arch = "wasm32"))]
5286    rim_mesh_index_buffer: Option<wgpu::Buffer>,
5287    /// Counts of scratch vertices/indices already uploaded this frame, so
5288    /// each fused chunk uploads only its newly appended region.
5289    #[cfg(not(target_arch = "wasm32"))]
5290    rim_mesh_uploaded_vertices: usize,
5291    #[cfg(not(target_arch = "wasm32"))]
5292    rim_mesh_uploaded_indices: usize,
5293    /// Lifetime count of rims drawn as band meshes — the test hook behind
5294    /// [`Self::rim_meshes_emitted`].
5295    #[cfg(not(target_arch = "wasm32"))]
5296    rim_meshes_emitted: u64,
5297    /// Submitted fill-area accounting (`CRANPOSE_FILL_DIAG`); idle unless
5298    /// the flag is set.
5299    #[cfg(not(target_arch = "wasm32"))]
5300    fill_area_diag: FillAreaDiag,
5301    /// Opaque static leading-span cache (`CRANPOSE_STATIC_SPAN` kill
5302    /// switch): the frame's byte-stable leading draws as one cached
5303    /// full-target blit.
5304    #[cfg(not(target_arch = "wasm32"))]
5305    static_span: StaticSpanCache,
5306}
5307
5308/// Running totals for retained-slot patch uploads, the paint-bandwidth
5309/// instrument: recolors upload 16-byte paint records (plus gradient stop
5310/// spans), coalesced per slot between the lowest and highest patched
5311/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
5312/// the untouched records inside each coalesced span.
5313#[cfg(not(target_arch = "wasm32"))]
5314#[derive(Default)]
5315struct ReplayUploadStats {
5316    calls: u64,
5317    patched_calls: u64,
5318    patches: u64,
5319    slots: u64,
5320    records: u64,
5321    bytes: u64,
5322    ideal_bytes: u64,
5323    max_frame_bytes: u64,
5324}
5325
5326#[cfg(not(target_arch = "wasm32"))]
5327impl ReplayUploadStats {
5328    /// One aggregate line roughly every few seconds: cheap enough to stay
5329    /// on unconditionally, which matters because the watch cannot take
5330    /// setprop-backed diag flags — its logcat is the only channel, and a
5331    /// measurement window must catch several lines. Counts every drain
5332    /// call (the drain runs several times per frame; only the first sees
5333    /// patches) so a target with zero paint traffic still reports an
5334    /// affirmative zero instead of silence, while the averages divide by
5335    /// PATCHED calls so they read as per-frame numbers.
5336    /// warn level: the platform loggers filter info on desktop.
5337    const REPORT_CALLS: u64 = 1024;
5338
5339    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
5340        self.calls += 1;
5341        if patches > 0 {
5342            self.patched_calls += 1;
5343            self.patches += patches;
5344            self.slots += slots;
5345            self.records += records;
5346            self.bytes += bytes;
5347            self.ideal_bytes += ideal;
5348            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
5349        }
5350        if self.calls >= Self::REPORT_CALLS {
5351            let patched = self.patched_calls.max(1);
5352            log::warn!(
5353                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
5354                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
5355                self.patched_calls,
5356                self.calls,
5357                self.bytes as f64 / patched as f64 / 1024.0,
5358                self.max_frame_bytes as f64 / 1024.0,
5359                self.ideal_bytes as f64 / patched as f64 / 1024.0,
5360                self.patches / patched,
5361                self.records / patched,
5362                self.slots / patched,
5363            );
5364            *self = Self::default();
5365        }
5366    }
5367}
5368
5369fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
5370    let filter = match sampling {
5371        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
5372        ImageSampling::Linear => wgpu::FilterMode::Linear,
5373    };
5374    wgpu::SamplerDescriptor {
5375        label: Some(match sampling {
5376            ImageSampling::Nearest => "Nearest Image Sampler",
5377            ImageSampling::Linear => "Linear Image Sampler",
5378        }),
5379        address_mode_u: wgpu::AddressMode::ClampToEdge,
5380        address_mode_v: wgpu::AddressMode::ClampToEdge,
5381        address_mode_w: wgpu::AddressMode::ClampToEdge,
5382        mag_filter: filter,
5383        min_filter: filter,
5384        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
5385        ..Default::default()
5386    }
5387}
5388
5389#[cfg(test)]
5390fn layer_raster_cache_candidate(
5391    layer: &LayerNode,
5392    root_scale: f32,
5393    has_backdrop_underlay: bool,
5394    allow_runtime_cache: bool,
5395) -> Option<(LayerRasterCacheKey, Rect)> {
5396    let mut layer_surface_requirements_cache = HashMap::new();
5397    let surface_requirements =
5398        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
5399    let runtime_cache_is_safe = allow_runtime_cache
5400        && surface_requirements
5401            .surface_requirements
5402            .has_isolating_requirement()
5403        && !surface_requirements.contains_runtime_shader;
5404    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
5405        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
5406        || runtime_cache_is_safe;
5407    if !cache_is_allowed {
5408        return None;
5409    }
5410    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
5411        return None;
5412    }
5413    // Not just this layer's own effect: a shader anywhere below it makes the
5414    // whole subtree change every frame with nothing in any hash to say so.
5415    if surface_requirements.contains_runtime_shader {
5416        return None;
5417    }
5418
5419    let logical_rect = estimate_layer_surface_rect(layer);
5420    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
5421    Some((
5422        LayerRasterCacheKey::new(
5423            layer.node_id,
5424            layer.target_content_hash(),
5425            layer.effect_hash(),
5426            logical_rect,
5427            pixel_size,
5428            ScaleBucket::from_scale(root_scale),
5429        ),
5430        logical_rect,
5431    ))
5432}
5433
5434impl GpuRenderer {
5435    pub fn new(
5436        device: Arc<wgpu::Device>,
5437        queue: Arc<wgpu::Queue>,
5438        surface_format: wgpu::TextureFormat,
5439        adapter_backend: wgpu::Backend,
5440        text_fonts: SoftwareTextFontSet,
5441        renderer_epoch: u64,
5442        store_feed_generation: u64,
5443    ) -> Self {
5444        #[cfg(target_arch = "wasm32")]
5445        let _ = store_feed_generation;
5446        // Construction time is worth a line of its own. Before pipelines were
5447        // built lazily this call linked every pipeline the frontend could ever
5448        // need, and on a GL device each link ended in a blocking
5449        // `glGetProgramiv` -- 25 s on an emulator, with nothing on screen. That
5450        // is fixed, but "fixed" is a claim that needs a number on each device,
5451        // and the per-pipeline `[gpu-pipeline]` lines cannot say what the
5452        // renderer costs to build when it builds no pipelines at all.
5453        let construction_started = Instant::now();
5454        let shape_batch_limits = ShapeBatchLimits::for_device(&device);
5455        let uniform_bind_group_layout =
5456            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5457                label: Some("Uniform Bind Group Layout"),
5458                entries: &[wgpu::BindGroupLayoutEntry {
5459                    binding: 0,
5460                    visibility: wgpu::ShaderStages::VERTEX,
5461                    ty: wgpu::BindingType::Buffer {
5462                        ty: wgpu::BufferBindingType::Uniform,
5463                        has_dynamic_offset: false,
5464                        min_binding_size: None,
5465                    },
5466                    count: None,
5467                }],
5468            });
5469        #[cfg(not(target_arch = "wasm32"))]
5470        let retained_glyph_uniform_bind_group_layout =
5471            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5472                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
5473                entries: &[wgpu::BindGroupLayoutEntry {
5474                    binding: 0,
5475                    visibility: wgpu::ShaderStages::VERTEX,
5476                    ty: wgpu::BindingType::Buffer {
5477                        ty: wgpu::BufferBindingType::Uniform,
5478                        has_dynamic_offset: true,
5479                        min_binding_size: wgpu::BufferSize::new(
5480                            std::mem::size_of::<Uniforms>() as u64
5481                        ),
5482                    },
5483                    count: None,
5484                }],
5485            });
5486
5487        // Read-only storage bindings where the device has them (so a whole
5488        // scene fits one batch); uniform arrays on WebGL-class devices, which
5489        // have no storage buffers in fragment shaders. The shape array is
5490        // visible to the vertex stage as well: the pipeline has no vertex
5491        // buffer and `vs_main` pulls quad corners from ShapeData. (Storage
5492        // mode is gated on `max_storage_buffers_per_shader_stage`, which GL
5493        // backends report as the minimum across stages, so a device that
5494        // cannot read storage from the vertex stage falls back to uniforms.)
5495        let mut shape_bind_group_layout_entries = vec![
5496            wgpu::BindGroupLayoutEntry {
5497                binding: 0,
5498                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
5499                ty: wgpu::BindingType::Buffer {
5500                    ty: shape_batch_limits.data_binding_type(),
5501                    has_dynamic_offset: false,
5502                    min_binding_size: None,
5503                },
5504                count: None,
5505            },
5506            wgpu::BindGroupLayoutEntry {
5507                binding: 1,
5508                visibility: wgpu::ShaderStages::FRAGMENT,
5509                ty: wgpu::BindingType::Buffer {
5510                    ty: shape_batch_limits.data_binding_type(),
5511                    has_dynamic_offset: false,
5512                    min_binding_size: None,
5513                },
5514                count: None,
5515            },
5516            // The similarity transform rides a dynamic offset so
5517            // retained draws sharing one captured batch can each
5518            // apply their own transform; ordinary batches pass
5519            // offset 0 into the identity buffer.
5520            wgpu::BindGroupLayoutEntry {
5521                binding: 2,
5522                visibility: wgpu::ShaderStages::VERTEX,
5523                ty: wgpu::BindingType::Buffer {
5524                    ty: wgpu::BufferBindingType::Uniform,
5525                    has_dynamic_offset: true,
5526                    min_binding_size: wgpu::BufferSize::new(
5527                        std::mem::size_of::<SimilarityTransform>() as u64,
5528                    ),
5529                },
5530                count: None,
5531            },
5532        ];
5533        // Retained-slot paint colors, read by the vertex stage under
5534        // `paint_select` (see `shape_shader_source`). Storage mode only:
5535        // the uniform-variant shader never declares the array, and
5536        // uniform-mode devices never host retained slots, so their layout
5537        // stays exactly the three-entry one the uniform pipeline expects.
5538        if shape_batch_limits.storage {
5539            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
5540                binding: 3,
5541                visibility: wgpu::ShaderStages::VERTEX,
5542                ty: wgpu::BindingType::Buffer {
5543                    ty: wgpu::BufferBindingType::Storage { read_only: true },
5544                    has_dynamic_offset: false,
5545                    min_binding_size: None,
5546                },
5547                count: None,
5548            });
5549        }
5550        let shape_bind_group_layout =
5551            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5552                label: Some("Shape Bind Group Layout"),
5553                entries: &shape_bind_group_layout_entries,
5554            });
5555
5556        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5557            label: Some("Identity Similarity Buffer"),
5558            size: std::mem::size_of::<SimilarityTransform>() as u64,
5559            usage: wgpu::BufferUsages::UNIFORM,
5560            mapped_at_creation: true,
5561        });
5562        identity_similarity_buffer
5563            .slice(..)
5564            .get_mapped_range_mut()
5565            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
5566        identity_similarity_buffer.unmap();
5567
5568        // Fresh-batch bind groups need a resource at the paint binding even
5569        // though their draws leave `paint_select` at 0.0 and never use the
5570        // value; one minimal buffer (a single never-read vec4) serves every
5571        // batch. Uniform-mode layouts have no paint entry, so none exists.
5572        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
5573            device.create_buffer(&wgpu::BufferDescriptor {
5574                label: Some("Dummy Paint Buffer"),
5575                size: std::mem::size_of::<[f32; 4]>() as u64,
5576                usage: wgpu::BufferUsages::STORAGE,
5577                mapped_at_creation: false,
5578            })
5579        });
5580        #[cfg(not(target_arch = "wasm32"))]
5581        let replay_slot_store = ReplaySlotStore::new(&device);
5582
5583        let pipeline = LazyGpuResource::new("shape/src-over");
5584        let pipeline_dst_out = LazyGpuResource::new("shape/dst-out");
5585        let pipeline_solid = LazyGpuResource::new("shape/solid-src-over");
5586        #[cfg(not(target_arch = "wasm32"))]
5587        let mesh_pipeline = LazyGpuResource::new("shape/mesh");
5588        // The instanced-quad selection is LATCHED here, once per renderer:
5589        // cached retained bundles encode whichever pipelines this resolves
5590        // to, so a per-draw env read could let a bundle replay a selection
5591        // the direct path no longer makes. Storage mode only — the
5592        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
5593        #[cfg(not(target_arch = "wasm32"))]
5594        let instanced_quads =
5595            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
5596                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5597                    label: Some("Instanced Quad Index Buffer"),
5598                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
5599                    usage: wgpu::BufferUsages::INDEX,
5600                    mapped_at_creation: true,
5601                });
5602                index_buffer
5603                    .slice(..)
5604                    .get_mapped_range_mut()
5605                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
5606                index_buffer.unmap();
5607                InstancedQuadPipelines {
5608                    pipeline: LazyGpuResource::new("shape/instanced-src-over"),
5609                    pipeline_dst_out: LazyGpuResource::new("shape/instanced-dst-out"),
5610                    pipeline_solid: LazyGpuResource::new("shape/instanced-solid"),
5611                    index_buffer,
5612                }
5613            });
5614
5615        let image_bind_group_layout =
5616            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5617                label: Some("Image Texture Bind Group Layout"),
5618                entries: &[
5619                    wgpu::BindGroupLayoutEntry {
5620                        binding: 0,
5621                        visibility: wgpu::ShaderStages::FRAGMENT,
5622                        ty: wgpu::BindingType::Texture {
5623                            multisampled: false,
5624                            view_dimension: wgpu::TextureViewDimension::D2,
5625                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
5626                        },
5627                        count: None,
5628                    },
5629                    wgpu::BindGroupLayoutEntry {
5630                        binding: 1,
5631                        visibility: wgpu::ShaderStages::FRAGMENT,
5632                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
5633                        count: None,
5634                    },
5635                ],
5636            });
5637
5638        let image_pipeline = LazyGpuResource::new("image/src-over");
5639        let image_pipeline_dst_out = LazyGpuResource::new("image/dst-out");
5640        let glyph_atlas_pipeline = LazyGpuResource::new("glyph/shared");
5641        #[cfg(not(target_arch = "wasm32"))]
5642        let retained_glyph_atlas_pipeline = LazyGpuResource::new("glyph/retained");
5643
5644        #[cfg(not(target_arch = "wasm32"))]
5645        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5646            label: Some("Frame Upload Buffer"),
5647            size: INITIAL_UPLOAD_BUFFER_BYTES,
5648            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
5649            mapped_at_creation: false,
5650        });
5651
5652        #[cfg(not(target_arch = "wasm32"))]
5653        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5654            label: Some("Uniform Buffer"),
5655            size: std::mem::size_of::<Uniforms>() as u64,
5656            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5657            mapped_at_creation: false,
5658        });
5659
5660        #[cfg(not(target_arch = "wasm32"))]
5661        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
5662            label: Some("Uniform Bind Group"),
5663            layout: &uniform_bind_group_layout,
5664            entries: &[wgpu::BindGroupEntry {
5665                binding: 0,
5666                resource: uniform_buffer.as_entire_binding(),
5667            }],
5668        });
5669
5670        #[cfg(not(target_arch = "wasm32"))]
5671        let shape_buffers = ShapeBatchBuffers::new(
5672            &device,
5673            &shape_bind_group_layout,
5674            &identity_similarity_buffer,
5675            dummy_paint_buffer.as_ref(),
5676            shape_batch_limits,
5677        );
5678
5679        let image_nearest_sampler =
5680            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
5681        let image_linear_sampler =
5682            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
5683        let text_glyph_atlas = TextGlyphAtlas::new(
5684            &device,
5685            &image_bind_group_layout,
5686            &image_nearest_sampler,
5687            TEXT_GLYPH_ATLAS_MIN_SIZE,
5688        );
5689
5690        #[cfg(not(target_arch = "wasm32"))]
5691        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5692            label: Some("Image Vertex Buffer"),
5693            size: (std::mem::size_of::<Vertex>() * 4) as u64,
5694            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
5695            mapped_at_creation: false,
5696        });
5697
5698        #[cfg(not(target_arch = "wasm32"))]
5699        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5700            label: Some("Image Index Buffer"),
5701            size: (std::mem::size_of::<u32>() * 6) as u64,
5702            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
5703            mapped_at_creation: false,
5704        });
5705        #[cfg(not(target_arch = "wasm32"))]
5706        let retained_glyph_uniform_stride = align_usize_to(
5707            std::mem::size_of::<Uniforms>(),
5708            (device.limits().min_uniform_buffer_offset_alignment as usize)
5709                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
5710        ) as u64;
5711        #[cfg(not(target_arch = "wasm32"))]
5712        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
5713        #[cfg(not(target_arch = "wasm32"))]
5714        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
5715            label: Some("Retained Glyph Uniform Buffer"),
5716            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
5717            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5718            mapped_at_creation: false,
5719        });
5720        #[cfg(not(target_arch = "wasm32"))]
5721        let retained_glyph_uniform_bind_group =
5722            device.create_bind_group(&wgpu::BindGroupDescriptor {
5723                label: Some("Retained Glyph Uniform Bind Group"),
5724                layout: &retained_glyph_uniform_bind_group_layout,
5725                entries: &[wgpu::BindGroupEntry {
5726                    binding: 0,
5727                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
5728                        buffer: &retained_glyph_uniform_buffer,
5729                        offset: 0,
5730                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
5731                    }),
5732                }],
5733            });
5734
5735        let effects_started = Instant::now();
5736        let effect_renderer = EffectRenderer::new(&device, surface_format, adapter_backend);
5737        let effects_ms = instant_ms(effects_started, Instant::now());
5738
5739        let renderer = Self {
5740            device,
5741            queue,
5742            renderer_epoch,
5743            #[cfg(not(target_arch = "wasm32"))]
5744            store_feed_generation,
5745            surface_format,
5746            adapter_backend,
5747            shape_batch_limits,
5748            pipeline,
5749            pipeline_dst_out,
5750            pipeline_solid,
5751            #[cfg(not(target_arch = "wasm32"))]
5752            mesh_pipeline,
5753            #[cfg(not(target_arch = "wasm32"))]
5754            instanced_quads,
5755            uniform_bind_group_layout,
5756            shape_bind_group_layout,
5757            dummy_paint_buffer,
5758            identity_similarity_buffer,
5759            #[cfg(not(target_arch = "wasm32"))]
5760            replay_slots: replay_slot_store,
5761            image_pipeline,
5762            image_pipeline_dst_out,
5763            glyph_atlas_pipeline,
5764            #[cfg(not(target_arch = "wasm32"))]
5765            retained_glyph_atlas_pipeline,
5766            image_bind_group_layout,
5767            #[cfg(not(target_arch = "wasm32"))]
5768            retained_glyph_uniform_bind_group_layout,
5769            image_nearest_sampler,
5770            image_linear_sampler,
5771            text_fonts,
5772            #[cfg(not(target_arch = "wasm32"))]
5773            upload_buffer,
5774            #[cfg(not(target_arch = "wasm32"))]
5775            uniform_buffer,
5776            #[cfg(not(target_arch = "wasm32"))]
5777            uniform_bind_group,
5778            #[cfg(not(target_arch = "wasm32"))]
5779            shape_buffers,
5780            #[cfg(not(target_arch = "wasm32"))]
5781            image_vertex_buffer,
5782            #[cfg(not(target_arch = "wasm32"))]
5783            image_index_buffer,
5784            #[cfg(not(target_arch = "wasm32"))]
5785            retained_glyph_uniform_buffer,
5786            #[cfg(not(target_arch = "wasm32"))]
5787            retained_glyph_uniform_bind_group,
5788            #[cfg(not(target_arch = "wasm32"))]
5789            retained_glyph_uniform_stride,
5790            #[cfg(not(target_arch = "wasm32"))]
5791            retained_glyph_uniform_capacity,
5792            #[cfg(not(target_arch = "wasm32"))]
5793            retained_glyph_uniform_cursor: 0,
5794            #[cfg(target_arch = "wasm32")]
5795            wasm_uniform_batches: Vec::new(),
5796            #[cfg(target_arch = "wasm32")]
5797            wasm_uniform_batch_cursor: 0,
5798            #[cfg(target_arch = "wasm32")]
5799            wasm_shape_batches: Vec::new(),
5800            #[cfg(target_arch = "wasm32")]
5801            wasm_shape_batch_cursor: 0,
5802            #[cfg(target_arch = "wasm32")]
5803            wasm_image_batches: Vec::new(),
5804            #[cfg(target_arch = "wasm32")]
5805            wasm_image_batch_cursor: 0,
5806            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
5807                MAX_TEXTURE_CACHE_ITEMS,
5808            ),
5809            image_texture_cache_bytes: 0,
5810            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
5811                MAX_TEXT_IMAGE_CACHE_ITEMS,
5812            ),
5813            text_glyph_atlas,
5814            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
5815                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
5816            ),
5817            #[cfg(not(target_arch = "wasm32"))]
5818            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
5819                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
5820            ),
5821            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
5822                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
5823            ),
5824            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
5825            scratch_shape_data: Vec::new(),
5826            scratch_gradients: Vec::new(),
5827            scratch_image_vertices: Vec::new(),
5828            scratch_image_indices: Vec::new(),
5829            scratch_image_cmds: Vec::new(),
5830            scratch_glyph_cmds: Vec::new(),
5831            scratch_text_glyph_run: Vec::new(),
5832            scratch_text_glyph_placements: Vec::new(),
5833            scratch_text_glyph_quads: Vec::new(),
5834            scratch_segment_items: Vec::new(),
5835            scratch_effect_ranges: Vec::new(),
5836            scratch_layer_events: Vec::new(),
5837            staged_uploads: StagedBufferUploads::default(),
5838            frame_graph_executor: WgpuFrameGraphExecutor::new(),
5839            deferred_offscreen_releases: Vec::new(),
5840            effect_renderer,
5841            layer_surface_cache: LayerSurfaceCache::new(),
5842            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
5843                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
5844            ),
5845            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
5846                MAX_SHADOW_SURFACE_CACHE_ITEMS,
5847            ),
5848            shadow_surface_cache_bytes: 0,
5849            frame_stats: gpu_stats::FrameStats::default(),
5850            last_frame_stats: None,
5851            pending_frame_warmup_frames: 0,
5852            frame_count: 0,
5853            gpu_stats_enabled: gpu_stats_enabled(),
5854            warning_state: RendererWarningState::default(),
5855            #[cfg(not(target_arch = "wasm32"))]
5856            replay_upload_stats: ReplayUploadStats::default(),
5857            #[cfg(not(target_arch = "wasm32"))]
5858            replay_color_patches: Vec::new(),
5859            #[cfg(not(target_arch = "wasm32"))]
5860            color_patch_scratch: Vec::new(),
5861            #[cfg(not(target_arch = "wasm32"))]
5862            replay_ack_confirmations: Vec::new(),
5863            #[cfg(not(target_arch = "wasm32"))]
5864            replay_generation_drops: 0,
5865            #[cfg(not(target_arch = "wasm32"))]
5866            retained_bundle_cache: RetainedBundleCache::new(),
5867            #[cfg(not(target_arch = "wasm32"))]
5868            rim_mesh_vertices: Vec::new(),
5869            #[cfg(not(target_arch = "wasm32"))]
5870            rim_mesh_indices: Vec::new(),
5871            #[cfg(not(target_arch = "wasm32"))]
5872            rim_mesh_vertex_buffer: None,
5873            #[cfg(not(target_arch = "wasm32"))]
5874            rim_mesh_index_buffer: None,
5875            #[cfg(not(target_arch = "wasm32"))]
5876            rim_mesh_uploaded_vertices: 0,
5877            #[cfg(not(target_arch = "wasm32"))]
5878            rim_mesh_uploaded_indices: 0,
5879            #[cfg(not(target_arch = "wasm32"))]
5880            rim_meshes_emitted: 0,
5881            #[cfg(not(target_arch = "wasm32"))]
5882            fill_area_diag: FillAreaDiag::default(),
5883            #[cfg(not(target_arch = "wasm32"))]
5884            static_span: StaticSpanCache::default(),
5885        };
5886        log::info!(
5887            "[gpu-init] {:?} renderer ready in {:.1} ms (effects {:.1} ms); \
5888             pipelines build on first use",
5889            adapter_backend,
5890            instant_ms(construction_started, Instant::now()),
5891            effects_ms,
5892        );
5893        renderer
5894    }
5895
5896    fn shape_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
5897        let resource = match blend_mode {
5898            BlendMode::DstOut => &self.pipeline_dst_out,
5899            _ => &self.pipeline,
5900        };
5901        resource.get_or_init(self.adapter_backend, || {
5902            create_shape_pipeline(
5903                &self.device,
5904                self.surface_format,
5905                &self.uniform_bind_group_layout,
5906                &self.shape_bind_group_layout,
5907                blend_mode,
5908                self.shape_batch_limits,
5909                "fs_main",
5910            )
5911        })
5912    }
5913
5914    /// The `fs_solid` twin of [`Self::shape_pipeline`], SrcOver only. Callers
5915    /// pick it exactly when the draw's shapes carry zero gradient stops; the
5916    /// coverage math is byte-identical, the gradient machinery is compiled
5917    /// out of the fragment stage.
5918    fn shape_pipeline_solid(&self) -> &wgpu::RenderPipeline {
5919        self.pipeline_solid.get_or_init(self.adapter_backend, || {
5920            create_shape_pipeline(
5921                &self.device,
5922                self.surface_format,
5923                &self.uniform_bind_group_layout,
5924                &self.shape_bind_group_layout,
5925                BlendMode::SrcOver,
5926                self.shape_batch_limits,
5927                "fs_solid",
5928            )
5929        })
5930    }
5931
5932    #[cfg(not(target_arch = "wasm32"))]
5933    fn mesh_pipeline(&self) -> &wgpu::RenderPipeline {
5934        self.mesh_pipeline.get_or_init(self.adapter_backend, || {
5935            create_mesh_shape_pipeline(
5936                &self.device,
5937                self.surface_format,
5938                &self.uniform_bind_group_layout,
5939                &self.shape_bind_group_layout,
5940                self.shape_batch_limits,
5941            )
5942        })
5943    }
5944
5945    #[cfg(not(target_arch = "wasm32"))]
5946    fn instanced_pipeline<'a>(
5947        &'a self,
5948        instanced: &'a InstancedQuadPipelines,
5949        blend_mode: BlendMode,
5950    ) -> &'a wgpu::RenderPipeline {
5951        let resource = match blend_mode {
5952            BlendMode::DstOut => &instanced.pipeline_dst_out,
5953            _ => &instanced.pipeline,
5954        };
5955        resource.get_or_init(self.adapter_backend, || {
5956            create_instanced_shape_pipeline(
5957                &self.device,
5958                self.surface_format,
5959                &self.uniform_bind_group_layout,
5960                &self.shape_bind_group_layout,
5961                blend_mode,
5962                self.shape_batch_limits,
5963                "fs_main",
5964            )
5965        })
5966    }
5967
5968    /// The `fs_solid` twin of [`Self::instanced_pipeline`], SrcOver only.
5969    #[cfg(not(target_arch = "wasm32"))]
5970    fn instanced_pipeline_solid<'a>(
5971        &'a self,
5972        instanced: &'a InstancedQuadPipelines,
5973    ) -> &'a wgpu::RenderPipeline {
5974        instanced
5975            .pipeline_solid
5976            .get_or_init(self.adapter_backend, || {
5977                create_instanced_shape_pipeline(
5978                    &self.device,
5979                    self.surface_format,
5980                    &self.uniform_bind_group_layout,
5981                    &self.shape_bind_group_layout,
5982                    BlendMode::SrcOver,
5983                    self.shape_batch_limits,
5984                    "fs_solid",
5985                )
5986            })
5987    }
5988
5989    fn image_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
5990        let resource = match blend_mode {
5991            BlendMode::DstOut => &self.image_pipeline_dst_out,
5992            _ => &self.image_pipeline,
5993        };
5994        resource.get_or_init(self.adapter_backend, || {
5995            create_image_pipeline(
5996                &self.device,
5997                self.surface_format,
5998                &self.uniform_bind_group_layout,
5999                &self.image_bind_group_layout,
6000                blend_mode,
6001            )
6002        })
6003    }
6004
6005    fn glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
6006        self.glyph_atlas_pipeline
6007            .get_or_init(self.adapter_backend, || {
6008                create_glyph_atlas_pipeline(
6009                    &self.device,
6010                    self.surface_format,
6011                    &self.uniform_bind_group_layout,
6012                    &self.image_bind_group_layout,
6013                )
6014            })
6015    }
6016
6017    #[cfg(not(target_arch = "wasm32"))]
6018    fn retained_glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
6019        self.retained_glyph_atlas_pipeline
6020            .get_or_init(self.adapter_backend, || {
6021                create_glyph_atlas_pipeline(
6022                    &self.device,
6023                    self.surface_format,
6024                    &self.retained_glyph_uniform_bind_group_layout,
6025                    &self.image_bind_group_layout,
6026                )
6027            })
6028    }
6029
6030    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
6031        if self.image_texture_cache.get(&image.id()).is_some() {
6032            return Ok(());
6033        }
6034
6035        let size = wgpu::Extent3d {
6036            width: image.width(),
6037            height: image.height(),
6038            depth_or_array_layers: 1,
6039        };
6040
6041        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
6042            label: Some("Image Texture"),
6043            size,
6044            mip_level_count: 1,
6045            sample_count: 1,
6046            dimension: wgpu::TextureDimension::D2,
6047            format: wgpu::TextureFormat::Rgba8Unorm,
6048            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6049            view_formats: &[],
6050        });
6051
6052        let upload_stats = self.frame_graph_executor.upload_texture(
6053            &self.queue,
6054            wgpu::TexelCopyTextureInfo {
6055                texture: &texture,
6056                mip_level: 0,
6057                origin: wgpu::Origin3d::ZERO,
6058                aspect: wgpu::TextureAspect::All,
6059            },
6060            image.pixels(),
6061            wgpu::TexelCopyBufferLayout {
6062                offset: 0,
6063                bytes_per_row: Some(4 * image.width()),
6064                rows_per_image: Some(image.height()),
6065            },
6066            size,
6067        );
6068        self.frame_stats.record_command_stats(upload_stats);
6069
6070        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
6071        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
6072        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
6073
6074        let bytes = image.width() as usize * image.height() as usize * 4;
6075        if let Some(replaced) = self.image_texture_cache.put(
6076            image.id(),
6077            CachedImageTexture {
6078                _texture: texture,
6079                _view: view,
6080                nearest_bind_group,
6081                linear_bind_group,
6082                bytes,
6083            },
6084        ) {
6085            self.image_texture_cache_bytes = self
6086                .image_texture_cache_bytes
6087                .saturating_sub(replaced.bytes);
6088        }
6089        self.image_texture_cache_bytes += bytes;
6090        // Byte-bounded eviction on top of the count bound: never evict the
6091        // entry just inserted (this frame draws it).
6092        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
6093            && self.image_texture_cache.len() > 1
6094        {
6095            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
6096                break;
6097            };
6098            self.image_texture_cache_bytes =
6099                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
6100        }
6101        Ok(())
6102    }
6103
6104    fn image_bind_group(
6105        &self,
6106        view: &wgpu::TextureView,
6107        sampler: &wgpu::Sampler,
6108    ) -> wgpu::BindGroup {
6109        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6110            label: Some("Image Texture Bind Group"),
6111            layout: &self.image_bind_group_layout,
6112            entries: &[
6113                wgpu::BindGroupEntry {
6114                    binding: 0,
6115                    resource: wgpu::BindingResource::TextureView(view),
6116                },
6117                wgpu::BindGroupEntry {
6118                    binding: 1,
6119                    resource: wgpu::BindingResource::Sampler(sampler),
6120                },
6121            ],
6122        })
6123    }
6124
6125    /// Acquire an offscreen target from the pool with stats tracking.
6126    /// Uses split borrows to avoid conflicting borrows on self.
6127    fn max_texture_dim(&self) -> u32 {
6128        self.effect_renderer.max_texture_dim()
6129    }
6130
6131    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
6132        self.effect_renderer
6133            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
6134    }
6135
6136    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
6137        self.acquire_offscreen(width, height)
6138    }
6139
6140    fn transient_offscreen_descriptor(
6141        &self,
6142        label: &'static str,
6143        width: u32,
6144        height: u32,
6145    ) -> FrameTextureDescriptor {
6146        let max_texture_dim = self.max_texture_dim();
6147        FrameTextureDescriptor::render_attachment(
6148            label,
6149            width.min(max_texture_dim),
6150            height.min(max_texture_dim),
6151            self.surface_format,
6152        )
6153    }
6154
6155    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
6156        self.deferred_offscreen_releases.push(target);
6157    }
6158
6159    fn flush_deferred_offscreen_releases(&mut self) {
6160        for target in self.deferred_offscreen_releases.drain(..) {
6161            self.effect_renderer.release_offscreen(target);
6162        }
6163    }
6164
6165    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
6166        if let LayerSurfaceTexture::Owned(target) = target {
6167            self.defer_offscreen_release(target);
6168        }
6169    }
6170
6171    fn cached_layer_surface(
6172        &mut self,
6173        key: &LayerRasterCacheKey,
6174    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
6175        self.layer_surface_cache.get(key, &self.frame_stats)
6176    }
6177
6178    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
6179        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
6180    }
6181
6182    fn insert_cached_layer_surface(
6183        &mut self,
6184        key: LayerRasterCacheKey,
6185        target: OffscreenTarget,
6186        logical_rect: Rect,
6187    ) -> Rc<OffscreenTarget> {
6188        self.layer_surface_cache
6189            .insert(key, target, logical_rect, &self.frame_stats)
6190    }
6191
6192    fn cached_shadow_surface(
6193        &mut self,
6194        key: &ShadowSurfaceCacheKey,
6195    ) -> Option<Rc<OffscreenTarget>> {
6196        self.shadow_surface_cache
6197            .get(key)
6198            .map(|cached| cached.target.clone())
6199    }
6200
6201    fn cached_shape_shadow_composite(
6202        &mut self,
6203        shadow: &ShadowDraw,
6204        width: u32,
6205        height: u32,
6206        root_scale: f32,
6207    ) -> Option<CachedShadowComposite> {
6208        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
6209            return None;
6210        }
6211
6212        let plan = shape_shadow_surface_plan(
6213            &shadow.shapes,
6214            shadow.clip,
6215            shadow.blur_radius,
6216            width,
6217            height,
6218            root_scale,
6219            self.max_texture_dim(),
6220        )?;
6221        let key = shape_shadow_surface_cache_key(
6222            &shadow.shapes,
6223            &shadow.brushes,
6224            plan.source_device_bounds,
6225            plan.pixel_radius,
6226            root_scale,
6227        )?;
6228        let cached = self.cached_shadow_surface(&key)?;
6229        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
6230        self.frame_stats.record_shadow_shape_cache_hit(
6231            plan.source_device_bounds.width,
6232            plan.source_device_bounds.height,
6233        );
6234
6235        let clip_scissor = shadow
6236            .clip
6237            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
6238        let scissor = clip_scissor.or(plan.processing_scissor);
6239        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
6240            mask.rect[0] -= viewport_offset[0];
6241            mask.rect[1] -= viewport_offset[1];
6242            mask
6243        });
6244        let dest_viewport = Some((
6245            viewport_offset[0],
6246            viewport_offset[1],
6247            plan.source_device_bounds.width as f32,
6248            plan.source_device_bounds.height as f32,
6249        ));
6250
6251        Some(CachedShadowComposite {
6252            source: cached,
6253            scissor,
6254            rounded_mask,
6255            dest_viewport,
6256        })
6257    }
6258
6259    fn insert_cached_shadow_surface(
6260        &mut self,
6261        key: ShadowSurfaceCacheKey,
6262        target: OffscreenTarget,
6263    ) {
6264        let byte_size = offscreen_byte_size(target.width, target.height);
6265        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
6266            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
6267                break;
6268            };
6269            self.shadow_surface_cache_bytes = self
6270                .shadow_surface_cache_bytes
6271                .saturating_sub(evicted_entry.byte_size);
6272        }
6273
6274        let cached = CachedShadowSurface {
6275            target: Rc::new(target),
6276            byte_size,
6277        };
6278        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
6279            self.shadow_surface_cache_bytes = self
6280                .shadow_surface_cache_bytes
6281                .saturating_sub(replaced_entry.byte_size);
6282        }
6283        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
6284    }
6285
6286    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
6287        is_render_effect_supported(effect)
6288    }
6289}
6290
6291struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
6292    renderer: &'renderer mut GpuRenderer,
6293    recorder: &'recorder mut C,
6294}
6295
6296impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
6297    #[allow(clippy::too_many_arguments)]
6298    fn render_range_with_layer_events_to_target_recorded(
6299        &mut self,
6300        target: &OffscreenTarget,
6301        shapes: &[DrawShape],
6302        brushes: &[Brush],
6303        images: &[ImageDraw],
6304        texts: &[TextDraw],
6305        shadow_draws: &[ShadowDraw],
6306        draw_ops: &[DrawOp],
6307        effect_layers: &[EffectLayer],
6308        backdrop_layers: &[BackdropLayer],
6309        z_start: usize,
6310        z_end: usize,
6311        excluded_effect_layer: Option<usize>,
6312        width: u32,
6313        height: u32,
6314        root_scale: f32,
6315        backdrop_underlay: Option<&OffscreenTarget>,
6316        initial_load_op: wgpu::LoadOp<wgpu::Color>,
6317    ) -> Result<(), String> {
6318        if z_start >= z_end {
6319            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
6320                self.clear_target_view_with_load_op(&target.view, initial_load_op);
6321            }
6322            return Ok(());
6323        }
6324
6325        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
6326        collect_effect_ranges(
6327            effect_layers,
6328            z_start,
6329            z_end,
6330            excluded_effect_layer,
6331            &mut effect_z_ranges,
6332        );
6333        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
6334        collect_layer_events(
6335            effect_layers,
6336            backdrop_layers,
6337            z_start,
6338            z_end,
6339            excluded_effect_layer,
6340            &mut events,
6341        );
6342
6343        let result = (|| -> Result<(), String> {
6344            let mut next_load_op = initial_load_op;
6345            let mut cursor_z = z_start;
6346            for event in &events {
6347                if event.z_index > cursor_z {
6348                    self.render_non_effect_segment(
6349                        &target.view,
6350                        shapes,
6351                        brushes,
6352                        images,
6353                        texts,
6354                        shadow_draws,
6355                        // Windowed scenes never carry retained draws — see
6356                        // `build_scene_window`.
6357                        &[],
6358                        draw_ops,
6359                        cursor_z,
6360                        event.z_index,
6361                        &effect_z_ranges,
6362                        width,
6363                        height,
6364                        root_scale,
6365                        next_load_op,
6366                    )?;
6367                    next_load_op = wgpu::LoadOp::Load;
6368                    cursor_z = event.z_index;
6369                } else if event.z_index < cursor_z {
6370                    continue;
6371                }
6372
6373                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
6374                    self.clear_target_view_with_load_op(&target.view, next_load_op);
6375                    next_load_op = wgpu::LoadOp::Load;
6376                }
6377
6378                match event.kind {
6379                    LayerEventKind::Backdrop(index) => {
6380                        let layer = &backdrop_layers[index];
6381                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
6382                            && backdrop_underlay_is_covered_by_local_content(
6383                                shapes,
6384                                brushes,
6385                                images,
6386                                shadow_draws,
6387                                draw_ops,
6388                                effect_layers,
6389                                backdrop_layers,
6390                                layer,
6391                            ) {
6392                            None
6393                        } else {
6394                            backdrop_underlay
6395                        };
6396                        execute_apply_backdrop_layer_to_target(
6397                            self,
6398                            target,
6399                            layer,
6400                            effective_backdrop_underlay,
6401                            width,
6402                            height,
6403                            root_scale,
6404                            None,
6405                        )?;
6406                    }
6407                    LayerEventKind::Effect(index) => {
6408                        let layer = &effect_layers[index];
6409                        if layer.z_start < cursor_z {
6410                            continue;
6411                        }
6412                        execute_render_effect_layer_to_target(
6413                            self,
6414                            target,
6415                            shapes,
6416                            brushes,
6417                            images,
6418                            texts,
6419                            shadow_draws,
6420                            draw_ops,
6421                            effect_layers,
6422                            backdrop_layers,
6423                            index,
6424                            backdrop_underlay,
6425                            width,
6426                            height,
6427                            root_scale,
6428                        )?;
6429                        cursor_z = cursor_z.max(layer.z_end);
6430                    }
6431                }
6432            }
6433
6434            if cursor_z < z_end {
6435                self.render_non_effect_segment(
6436                    &target.view,
6437                    shapes,
6438                    brushes,
6439                    images,
6440                    texts,
6441                    shadow_draws,
6442                    &[],
6443                    draw_ops,
6444                    cursor_z,
6445                    z_end,
6446                    &effect_z_ranges,
6447                    width,
6448                    height,
6449                    root_scale,
6450                    next_load_op,
6451                )?;
6452            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
6453                self.clear_target_view_with_load_op(&target.view, next_load_op);
6454            }
6455
6456            Ok(())
6457        })();
6458
6459        self.renderer.scratch_effect_ranges = effect_z_ranges;
6460        self.renderer.scratch_layer_events = events;
6461        result
6462    }
6463
6464    #[allow(clippy::too_many_arguments)]
6465    fn record_shader_composite(
6466        &mut self,
6467        source: &OffscreenTarget,
6468        shader: &RuntimeShader,
6469        effect_rect: [f32; 4],
6470        dest_view: &wgpu::TextureView,
6471        alpha: f32,
6472        load_op: wgpu::LoadOp<wgpu::Color>,
6473        scissor: Option<(u32, u32, u32, u32)>,
6474        blend_mode: BlendMode,
6475        dest_viewport: Option<(f32, f32, f32, f32)>,
6476        sample_mode: CompositeSampleMode,
6477    ) {
6478        let device = self.renderer.device.clone();
6479        if let Some(viewport) = direct_shader_composite_viewport(
6480            alpha,
6481            blend_mode,
6482            dest_viewport,
6483            sample_mode,
6484            (source.width, source.height),
6485        ) {
6486            let shader_applied = self
6487                .renderer
6488                .effect_renderer
6489                .encode_shader_src_over_to_view(
6490                    self.recorder,
6491                    &device,
6492                    source,
6493                    dest_view,
6494                    shader,
6495                    effect_rect,
6496                    load_op,
6497                    scissor,
6498                    viewport,
6499                );
6500            if shader_applied {
6501                self.renderer
6502                    .effect_renderer
6503                    .debug_effects
6504                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
6505                self.recorder.record_pass();
6506                self.renderer.effect_renderer.record_composite_pass();
6507                return;
6508            }
6509        }
6510        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
6511            "Shader Effect Composite Scratch",
6512            source.width,
6513            source.height,
6514        );
6515        let scratch = self
6516            .recorder
6517            .acquire_transient_offscreen(&device, scratch_descriptor);
6518        let shader_applied = {
6519            self.renderer.effect_renderer.encode_shader(
6520                self.recorder,
6521                &device,
6522                source,
6523                &scratch.view,
6524                shader,
6525                effect_rect,
6526            )
6527        };
6528        let composite_source = if shader_applied {
6529            self.renderer
6530                .effect_renderer
6531                .debug_effects
6532                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
6533            self.recorder.record_pass();
6534            &scratch
6535        } else {
6536            source
6537        };
6538        {
6539            self.renderer
6540                .effect_renderer
6541                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6542                    self.recorder,
6543                    &device,
6544                    composite_source,
6545                    dest_view,
6546                    alpha,
6547                    load_op,
6548                    scissor,
6549                    None,
6550                    supported_blend_mode(blend_mode),
6551                    dest_viewport,
6552                    sample_mode,
6553                );
6554        }
6555        self.recorder.record_pass();
6556        self.renderer.effect_renderer.record_composite_pass();
6557        self.recorder
6558            .release_transient_offscreen(scratch_descriptor, scratch);
6559    }
6560
6561    #[allow(clippy::too_many_arguments)]
6562    fn record_shader_projective_composite(
6563        &mut self,
6564        source: &OffscreenTarget,
6565        shader: &RuntimeShader,
6566        effect_rect: [f32; 4],
6567        dest_view: &wgpu::TextureView,
6568        viewport: (u32, u32),
6569        source_size: (f32, f32),
6570        inverse_matrix: [[f32; 3]; 3],
6571        dest_bounds: [[f32; 2]; 4],
6572        alpha: f32,
6573        load_op: wgpu::LoadOp<wgpu::Color>,
6574        scissor: Option<(u32, u32, u32, u32)>,
6575        blend_mode: BlendMode,
6576        sample_mode: CompositeSampleMode,
6577    ) {
6578        if projective_dest_bounds_rect(dest_bounds).is_none() {
6579            return;
6580        }
6581        let device = self.renderer.device.clone();
6582        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
6583            "Shader Projective Composite Scratch",
6584            source.width,
6585            source.height,
6586        );
6587        let scratch = self
6588            .recorder
6589            .acquire_transient_offscreen(&device, scratch_descriptor);
6590        let shader_applied = {
6591            self.renderer.effect_renderer.encode_shader(
6592                self.recorder,
6593                &device,
6594                source,
6595                &scratch.view,
6596                shader,
6597                effect_rect,
6598            )
6599        };
6600        let composite_source = if shader_applied {
6601            self.renderer
6602                .effect_renderer
6603                .debug_effects
6604                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
6605            self.recorder.record_pass();
6606            &scratch
6607        } else {
6608            source
6609        };
6610        let composited = {
6611            self.renderer
6612                .effect_renderer
6613                .encode_composite_to_view_projective(
6614                    self.recorder,
6615                    &device,
6616                    composite_source,
6617                    dest_view,
6618                    viewport,
6619                    source_size,
6620                    inverse_matrix,
6621                    dest_bounds,
6622                    alpha,
6623                    load_op,
6624                    scissor,
6625                    supported_blend_mode(blend_mode),
6626                    sample_mode,
6627                )
6628        };
6629        if composited {
6630            self.recorder.record_pass();
6631            self.renderer.effect_renderer.record_composite_pass();
6632        }
6633        self.recorder
6634            .release_transient_offscreen(scratch_descriptor, scratch);
6635    }
6636
6637    #[allow(clippy::too_many_arguments)]
6638    fn record_effect_with_direct_shader_tail_composite(
6639        &mut self,
6640        source: &OffscreenTarget,
6641        first_effect: &RenderEffect,
6642        shader: &RuntimeShader,
6643        effect_rect: [f32; 4],
6644        dest_view: &wgpu::TextureView,
6645        load_op: wgpu::LoadOp<wgpu::Color>,
6646        scissor: Option<(u32, u32, u32, u32)>,
6647        dest_viewport: (f32, f32, f32, f32),
6648    ) -> Result<bool, String> {
6649        let device = self.renderer.device.clone();
6650        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
6651            "Render Effect Direct Shader Tail Intermediate",
6652            source.width,
6653            source.height,
6654        );
6655        let intermediate = self
6656            .recorder
6657            .acquire_transient_offscreen(&device, intermediate_descriptor);
6658        let effect_scratch_targets = self
6659            .renderer
6660            .effect_renderer
6661            .acquire_recorded_effect_scratch_targets(
6662                self.recorder,
6663                &device,
6664                first_effect,
6665                source.width,
6666                source.height,
6667                self.renderer.surface_format,
6668            );
6669        let first_passes = {
6670            let mut effect_scratch_refs = effect_scratch_targets.refs();
6671            let pass_count = self.renderer.effect_renderer.encode_effect(
6672                self.recorder,
6673                &device,
6674                source,
6675                &intermediate.view,
6676                first_effect,
6677                effect_rect,
6678                &mut effect_scratch_refs,
6679            );
6680            match pass_count {
6681                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
6682                Err(error) => Err(error),
6683            }
6684        };
6685        let first_passes = match first_passes {
6686            Ok(pass_count) => pass_count,
6687            Err(error) => {
6688                effect_scratch_targets.release_into(self.recorder);
6689                self.recorder
6690                    .release_transient_offscreen(intermediate_descriptor, intermediate);
6691                return Err(error);
6692            }
6693        };
6694        let shader_applied = self
6695            .renderer
6696            .effect_renderer
6697            .encode_shader_src_over_to_view(
6698                self.recorder,
6699                &device,
6700                &intermediate,
6701                dest_view,
6702                shader,
6703                effect_rect,
6704                load_op,
6705                scissor,
6706                dest_viewport,
6707            );
6708        self.recorder
6709            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
6710        effect_scratch_targets.release_into(self.recorder);
6711        self.recorder
6712            .release_transient_offscreen(intermediate_descriptor, intermediate);
6713        if !shader_applied {
6714            return Ok(false);
6715        }
6716        self.renderer
6717            .effect_renderer
6718            .debug_effects
6719            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
6720        self.renderer.effect_renderer.record_composite_pass();
6721        Ok(true)
6722    }
6723
6724    #[allow(clippy::too_many_arguments)]
6725    fn record_effect_composite(
6726        &mut self,
6727        source: &OffscreenTarget,
6728        effect: &RenderEffect,
6729        effect_rect: [f32; 4],
6730        dest_view: &wgpu::TextureView,
6731        alpha: f32,
6732        load_op: wgpu::LoadOp<wgpu::Color>,
6733        scissor: Option<(u32, u32, u32, u32)>,
6734        blend_mode: BlendMode,
6735        dest_viewport: Option<(f32, f32, f32, f32)>,
6736        sample_mode: CompositeSampleMode,
6737    ) -> Result<(), String> {
6738        if let (
6739            RenderEffect::Chain { first, second },
6740            Some(viewport),
6741            BlendMode::SrcOver,
6742            CompositeSampleMode::Linear,
6743        ) = (
6744            effect,
6745            dest_viewport,
6746            supported_blend_mode(blend_mode),
6747            sample_mode,
6748        ) {
6749            if let (
6750                RenderEffect::Blur {
6751                    radius_x,
6752                    radius_y,
6753                    edge_treatment,
6754                },
6755                RenderEffect::Shader { shader },
6756            ) = (first.as_ref(), second.as_ref())
6757            {
6758                if *radius_x > 0.0 || *radius_y > 0.0 {
6759                    let device = self.renderer.device.clone();
6760                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
6761                        "Blur Rounded Mask Scratch",
6762                        source.width,
6763                        source.height,
6764                    );
6765                    let scratch = self
6766                        .recorder
6767                        .acquire_transient_offscreen(&device, scratch_descriptor);
6768                    let fused = self
6769                        .renderer
6770                        .effect_renderer
6771                        .encode_blur_then_rounded_mask_src_over_to_view(
6772                            self.recorder,
6773                            &device,
6774                            source,
6775                            &scratch,
6776                            dest_view,
6777                            *radius_x,
6778                            *radius_y,
6779                            *edge_treatment,
6780                            shader,
6781                            effect_rect,
6782                            load_op,
6783                            scissor,
6784                            viewport,
6785                        );
6786                    if fused {
6787                        self.recorder.record_passes(2);
6788                        self.renderer.effect_renderer.record_blur_pass();
6789                        self.renderer
6790                            .effect_renderer
6791                            .debug_effects
6792                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
6793                        self.renderer.effect_renderer.record_composite_pass();
6794                        self.recorder
6795                            .release_transient_offscreen(scratch_descriptor, scratch);
6796                        return Ok(());
6797                    }
6798                    self.recorder
6799                        .release_transient_offscreen(scratch_descriptor, scratch);
6800                }
6801            }
6802        }
6803        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
6804            effect,
6805            alpha,
6806            blend_mode,
6807            dest_viewport,
6808            sample_mode,
6809            (source.width, source.height),
6810        ) {
6811            if self.record_effect_with_direct_shader_tail_composite(
6812                source,
6813                first_effect,
6814                shader,
6815                effect_rect,
6816                dest_view,
6817                load_op,
6818                scissor,
6819                viewport,
6820            )? {
6821                return Ok(());
6822            }
6823        }
6824        let device = self.renderer.device.clone();
6825        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
6826            "Render Effect Composite Scratch",
6827            source.width,
6828            source.height,
6829        );
6830        let scratch = self
6831            .recorder
6832            .acquire_transient_offscreen(&device, scratch_descriptor);
6833        let effect_scratch_targets = self
6834            .renderer
6835            .effect_renderer
6836            .acquire_recorded_effect_scratch_targets(
6837                self.recorder,
6838                &device,
6839                effect,
6840                source.width,
6841                source.height,
6842                self.renderer.surface_format,
6843            );
6844        let effect_passes = {
6845            let mut effect_scratch_refs = effect_scratch_targets.refs();
6846            let pass_count = self.renderer.effect_renderer.encode_effect(
6847                self.recorder,
6848                &device,
6849                source,
6850                &scratch.view,
6851                effect,
6852                effect_rect,
6853                &mut effect_scratch_refs,
6854            )?;
6855            effect_scratch_refs.assert_consumed()?;
6856            Ok(pass_count)
6857        };
6858        let effect_passes = match effect_passes {
6859            Ok(pass_count) => pass_count,
6860            Err(error) => {
6861                effect_scratch_targets.release_into(self.recorder);
6862                self.recorder
6863                    .release_transient_offscreen(scratch_descriptor, scratch);
6864                return Err(error);
6865            }
6866        };
6867        {
6868            self.renderer
6869                .effect_renderer
6870                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6871                    self.recorder,
6872                    &device,
6873                    &scratch,
6874                    dest_view,
6875                    alpha,
6876                    load_op,
6877                    scissor,
6878                    None,
6879                    supported_blend_mode(blend_mode),
6880                    dest_viewport,
6881                    sample_mode,
6882                );
6883        }
6884        self.recorder.record_passes(effect_passes.saturating_add(1));
6885        self.renderer.effect_renderer.record_composite_pass();
6886        effect_scratch_targets.release_into(self.recorder);
6887        self.recorder
6888            .release_transient_offscreen(scratch_descriptor, scratch);
6889        Ok(())
6890    }
6891
6892    #[allow(clippy::too_many_arguments)]
6893    fn record_effect_projective_composite(
6894        &mut self,
6895        source: &OffscreenTarget,
6896        effect: &RenderEffect,
6897        effect_rect: [f32; 4],
6898        dest_view: &wgpu::TextureView,
6899        viewport: (u32, u32),
6900        source_size: (f32, f32),
6901        inverse_matrix: [[f32; 3]; 3],
6902        dest_bounds: [[f32; 2]; 4],
6903        alpha: f32,
6904        load_op: wgpu::LoadOp<wgpu::Color>,
6905        scissor: Option<(u32, u32, u32, u32)>,
6906        blend_mode: BlendMode,
6907        sample_mode: CompositeSampleMode,
6908    ) -> Result<(), String> {
6909        if projective_dest_bounds_rect(dest_bounds).is_none() {
6910            return Ok(());
6911        }
6912        let device = self.renderer.device.clone();
6913        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
6914            "Render Effect Projective Composite Scratch",
6915            source.width,
6916            source.height,
6917        );
6918        let scratch = self
6919            .recorder
6920            .acquire_transient_offscreen(&device, scratch_descriptor);
6921        let effect_scratch_targets = self
6922            .renderer
6923            .effect_renderer
6924            .acquire_recorded_effect_scratch_targets(
6925                self.recorder,
6926                &device,
6927                effect,
6928                source.width,
6929                source.height,
6930                self.renderer.surface_format,
6931            );
6932        let effect_passes = {
6933            let mut effect_scratch_refs = effect_scratch_targets.refs();
6934            let pass_count = self.renderer.effect_renderer.encode_effect(
6935                self.recorder,
6936                &device,
6937                source,
6938                &scratch.view,
6939                effect,
6940                effect_rect,
6941                &mut effect_scratch_refs,
6942            )?;
6943            effect_scratch_refs.assert_consumed()?;
6944            Ok(pass_count)
6945        };
6946        let effect_passes = match effect_passes {
6947            Ok(pass_count) => pass_count,
6948            Err(error) => {
6949                effect_scratch_targets.release_into(self.recorder);
6950                self.recorder
6951                    .release_transient_offscreen(scratch_descriptor, scratch);
6952                return Err(error);
6953            }
6954        };
6955        let composited = {
6956            self.renderer
6957                .effect_renderer
6958                .encode_composite_to_view_projective(
6959                    self.recorder,
6960                    &device,
6961                    &scratch,
6962                    dest_view,
6963                    viewport,
6964                    source_size,
6965                    inverse_matrix,
6966                    dest_bounds,
6967                    alpha,
6968                    load_op,
6969                    scissor,
6970                    supported_blend_mode(blend_mode),
6971                    sample_mode,
6972                )
6973        };
6974        if composited {
6975            self.recorder.record_passes(effect_passes.saturating_add(1));
6976            self.renderer.effect_renderer.record_composite_pass();
6977        } else {
6978            self.recorder.record_passes(effect_passes);
6979        }
6980        effect_scratch_targets.release_into(self.recorder);
6981        self.recorder
6982            .release_transient_offscreen(scratch_descriptor, scratch);
6983        Ok(())
6984    }
6985}
6986
6987impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
6988    fn max_texture_dim(&self) -> u32 {
6989        self.renderer.max_texture_dim()
6990    }
6991
6992    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
6993        self.renderer.acquire_retained_surface(width, height)
6994    }
6995
6996    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
6997        let descriptor =
6998            self.renderer
6999                .transient_offscreen_descriptor("Frame Surface", width, height);
7000        self.recorder
7001            .acquire_transient_offscreen(&self.renderer.device, descriptor)
7002    }
7003
7004    fn release_frame_surface(&mut self, target: OffscreenTarget) {
7005        let descriptor = self.renderer.transient_offscreen_descriptor(
7006            "Frame Surface",
7007            target.width,
7008            target.height,
7009        );
7010        self.recorder
7011            .release_transient_offscreen(descriptor, target);
7012    }
7013
7014    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
7015        self.renderer.release_layer_surface_target(target);
7016    }
7017
7018    fn cached_layer_surface(
7019        &mut self,
7020        key: &LayerRasterCacheKey,
7021    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
7022        self.renderer.cached_layer_surface(key)
7023    }
7024
7025    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
7026        self.renderer.admit_layer_surface_cache_miss(key)
7027    }
7028
7029    fn insert_cached_layer_surface(
7030        &mut self,
7031        key: LayerRasterCacheKey,
7032        target: OffscreenTarget,
7033        logical_rect: Rect,
7034    ) -> Rc<OffscreenTarget> {
7035        self.renderer
7036            .insert_cached_layer_surface(key, target, logical_rect)
7037    }
7038
7039    fn clear_target_view_with_load_op(
7040        &mut self,
7041        target_view: &wgpu::TextureView,
7042        load_op: wgpu::LoadOp<wgpu::Color>,
7043    ) {
7044        {
7045            let _clear = self
7046                .recorder
7047                .encoder()
7048                .begin_render_pass(&wgpu::RenderPassDescriptor {
7049                    label: Some("Layer Event Clear Pass"),
7050                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7051                        view: target_view,
7052                        resolve_target: None,
7053                        depth_slice: None,
7054                        ops: wgpu::Operations {
7055                            load: load_op,
7056                            store: wgpu::StoreOp::Store,
7057                        },
7058                    })],
7059                    depth_stencil_attachment: None,
7060                    timestamp_writes: None,
7061                    occlusion_query_set: None,
7062                    multiview_mask: None,
7063                });
7064        }
7065        self.recorder.record_pass();
7066    }
7067
7068    #[allow(clippy::too_many_arguments)]
7069    fn render_non_effect_segment(
7070        &mut self,
7071        target_view: &wgpu::TextureView,
7072        shapes: &[DrawShape],
7073        brushes: &[Brush],
7074        images: &[ImageDraw],
7075        texts: &[TextDraw],
7076        shadow_draws: &[ShadowDraw],
7077        retained_draws: &[RetainedDraw],
7078        draw_ops: &[DrawOp],
7079        z_start: usize,
7080        z_end: usize,
7081        effect_z_ranges: &[Range<usize>],
7082        width: u32,
7083        height: u32,
7084        root_scale: f32,
7085        initial_load_op: wgpu::LoadOp<wgpu::Color>,
7086    ) -> Result<(), String> {
7087        self.render_non_effect_segment_with_composites(
7088            target_view,
7089            shapes,
7090            brushes,
7091            images,
7092            texts,
7093            shadow_draws,
7094            retained_draws,
7095            draw_ops,
7096            z_start,
7097            z_end,
7098            effect_z_ranges,
7099            &[],
7100            &[],
7101            width,
7102            height,
7103            root_scale,
7104            initial_load_op,
7105        )
7106    }
7107
7108    #[allow(clippy::too_many_arguments)]
7109    fn render_non_effect_segment_with_composites(
7110        &mut self,
7111        target_view: &wgpu::TextureView,
7112        shapes: &[DrawShape],
7113        brushes: &[Brush],
7114        images: &[ImageDraw],
7115        texts: &[TextDraw],
7116        shadow_draws: &[ShadowDraw],
7117        retained_draws: &[RetainedDraw],
7118        draw_ops: &[DrawOp],
7119        z_start: usize,
7120        z_end: usize,
7121        effect_z_ranges: &[Range<usize>],
7122        composites: &[(usize, CompositeBatchItem<'_>)],
7123        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7124        width: u32,
7125        height: u32,
7126        root_scale: f32,
7127        initial_load_op: wgpu::LoadOp<wgpu::Color>,
7128    ) -> Result<(), String> {
7129        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
7130        collect_non_effect_segment_items(
7131            shapes,
7132            images,
7133            texts,
7134            shadow_draws,
7135            draw_ops,
7136            z_start,
7137            z_end,
7138            effect_z_ranges,
7139            width,
7140            height,
7141            root_scale,
7142            &mut ordered_items,
7143        );
7144        #[cfg(not(target_arch = "wasm32"))]
7145        let raw_shadow_items = ordered_items
7146            .iter()
7147            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
7148            .count();
7149        let culled_shadow_items = retain_renderable_shadow_items(
7150            &mut ordered_items,
7151            shadow_draws,
7152            width,
7153            height,
7154            root_scale,
7155            self.renderer.max_texture_dim(),
7156        );
7157        #[cfg(target_arch = "wasm32")]
7158        let _ = culled_shadow_items;
7159        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
7160        ordered_items.extend(
7161            composites
7162                .iter()
7163                .enumerate()
7164                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
7165        );
7166        ordered_items.extend(
7167            shader_composites
7168                .iter()
7169                .enumerate()
7170                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
7171        );
7172        for (z_index, item) in &mut ordered_items {
7173            let SegmentDrawItem::Shadow(shadow_index) = *item else {
7174                continue;
7175            };
7176            let Some(composite) = self.renderer.cached_shape_shadow_composite(
7177                &shadow_draws[shadow_index],
7178                width,
7179                height,
7180                root_scale,
7181            ) else {
7182                continue;
7183            };
7184            let composite_index = composites.len() + cached_shadow_composites.len();
7185            cached_shadow_composites.push((*z_index, composite));
7186            *item = SegmentDrawItem::Composite(composite_index);
7187        }
7188        let mut merged_composites = Vec::with_capacity(
7189            composites
7190                .len()
7191                .saturating_add(cached_shadow_composites.len()),
7192        );
7193        merged_composites.extend(composites.iter().copied());
7194        merged_composites.extend(
7195            cached_shadow_composites
7196                .iter()
7197                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
7198        );
7199        // Z indices are unique — the scene hands every op its own `next_z` — so an
7200        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
7201        // the stable sort's scratch allocation, paid here once per segment per frame.
7202        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
7203        #[cfg(not(target_arch = "wasm32"))]
7204        maybe_print_segment_diag(
7205            z_start..z_end,
7206            &ordered_items,
7207            shapes,
7208            brushes,
7209            images,
7210            SegmentDiagCounts {
7211                raw_shadow_items,
7212                culled_shadow_items,
7213                cached_shadow_composites: cached_shadow_composites.len(),
7214                composite_items: merged_composites.len(),
7215                shader_composite_items: shader_composites.len(),
7216            },
7217            self.renderer.shape_batch_limits,
7218        );
7219        let result = if ordered_items.is_empty() {
7220            Ok(SegmentCommandEncodeOutcome { first_batch: true })
7221        } else {
7222            self.renderer.encode_non_effect_segment_commands(
7223                self.recorder,
7224                target_view,
7225                &ordered_items,
7226                &merged_composites,
7227                shader_composites,
7228                shapes,
7229                brushes,
7230                images,
7231                texts,
7232                shadow_draws,
7233                retained_draws,
7234                initial_load_op,
7235                width,
7236                height,
7237                root_scale,
7238            )
7239        };
7240        self.renderer.scratch_segment_items = ordered_items;
7241        let outcome = result?;
7242        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
7243            self.clear_target_view_with_load_op(target_view, initial_load_op);
7244        }
7245        Ok(())
7246    }
7247
7248    fn render_range_with_layer_events_to_target(
7249        &mut self,
7250        target: &OffscreenTarget,
7251        shapes: &[DrawShape],
7252        brushes: &[Brush],
7253        images: &[ImageDraw],
7254        texts: &[TextDraw],
7255        shadow_draws: &[ShadowDraw],
7256        draw_ops: &[DrawOp],
7257        effect_layers: &[EffectLayer],
7258        backdrop_layers: &[BackdropLayer],
7259        z_start: usize,
7260        z_end: usize,
7261        excluded_effect_layer: Option<usize>,
7262        width: u32,
7263        height: u32,
7264        root_scale: f32,
7265        backdrop_underlay: Option<&OffscreenTarget>,
7266        initial_load_op: wgpu::LoadOp<wgpu::Color>,
7267    ) -> Result<(), String> {
7268        self.render_range_with_layer_events_to_target_recorded(
7269            target,
7270            shapes,
7271            brushes,
7272            images,
7273            texts,
7274            shadow_draws,
7275            draw_ops,
7276            effect_layers,
7277            backdrop_layers,
7278            z_start,
7279            z_end,
7280            excluded_effect_layer,
7281            width,
7282            height,
7283            root_scale,
7284            backdrop_underlay,
7285            initial_load_op,
7286        )
7287    }
7288
7289    fn render_shadow_draw(
7290        &mut self,
7291        target_view: &wgpu::TextureView,
7292        shadow: &ShadowDraw,
7293        width: u32,
7294        height: u32,
7295        root_scale: f32,
7296    ) {
7297        self.renderer.encode_shadow_draw(
7298            self.recorder,
7299            target_view,
7300            shadow,
7301            width,
7302            height,
7303            root_scale,
7304        );
7305    }
7306
7307    fn composite_to_view_projective(
7308        &mut self,
7309        source: &OffscreenTarget,
7310        dest_view: &wgpu::TextureView,
7311        viewport: (u32, u32),
7312        source_size: (f32, f32),
7313        inverse_matrix: [[f32; 3]; 3],
7314        dest_bounds: [[f32; 2]; 4],
7315        alpha: f32,
7316        load_op: wgpu::LoadOp<wgpu::Color>,
7317        scissor: Option<(u32, u32, u32, u32)>,
7318        blend_mode: BlendMode,
7319        sample_mode: CompositeSampleMode,
7320    ) {
7321        let device = self.renderer.device.clone();
7322        let composited = {
7323            self.renderer
7324                .effect_renderer
7325                .encode_composite_to_view_projective(
7326                    self.recorder,
7327                    &device,
7328                    source,
7329                    dest_view,
7330                    viewport,
7331                    source_size,
7332                    inverse_matrix,
7333                    dest_bounds,
7334                    alpha,
7335                    load_op,
7336                    scissor,
7337                    supported_blend_mode(blend_mode),
7338                    sample_mode,
7339                )
7340        };
7341        if composited {
7342            self.recorder.record_pass();
7343            self.renderer.effect_renderer.record_composite_pass();
7344        }
7345    }
7346
7347    fn composite_projective_surfaces_to_view(
7348        &mut self,
7349        dest_view: &wgpu::TextureView,
7350        viewport: (u32, u32),
7351        composites: &[ProjectiveSurfaceComposite<'_>],
7352    ) {
7353        let device = self.renderer.device.clone();
7354        let mut composite_count = 0_u32;
7355        for composite in composites
7356            .iter()
7357            .copied()
7358            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
7359        {
7360            let composited = {
7361                self.renderer
7362                    .effect_renderer
7363                    .encode_composite_to_view_projective(
7364                        self.recorder,
7365                        &device,
7366                        composite.source,
7367                        dest_view,
7368                        viewport,
7369                        composite.source_size,
7370                        composite.inverse_matrix,
7371                        composite.dest_bounds,
7372                        composite.alpha,
7373                        composite.load_op,
7374                        composite.scissor,
7375                        supported_blend_mode(composite.blend_mode),
7376                        composite.sample_mode,
7377                    )
7378            };
7379            if composited {
7380                composite_count = composite_count.saturating_add(1);
7381            }
7382        }
7383        if composite_count > 0 {
7384            self.recorder.record_passes(composite_count);
7385            self.renderer
7386                .effect_renderer
7387                .debug_composites
7388                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
7389        }
7390    }
7391
7392    fn composite_surface_batch_to_view(
7393        &mut self,
7394        dest_view: &wgpu::TextureView,
7395        viewport: (u32, u32),
7396        load_op: wgpu::LoadOp<wgpu::Color>,
7397        composites: &[CompositeBatchItem<'_>],
7398    ) {
7399        if composites.is_empty() {
7400            return;
7401        }
7402        let device = self.renderer.device.clone();
7403        self.renderer
7404            .effect_renderer
7405            .encode_composite_batch_to_view_pass(
7406                self.recorder,
7407                &device,
7408                dest_view,
7409                viewport,
7410                load_op,
7411                composites,
7412            );
7413        self.recorder.record_pass();
7414        self.renderer.effect_renderer.record_composite_pass();
7415    }
7416
7417    fn copy_texture_region_to_target(
7418        &mut self,
7419        source: &OffscreenTarget,
7420        source_origin: (u32, u32),
7421        target: &OffscreenTarget,
7422        size: (u32, u32),
7423    ) -> bool {
7424        let (width, height) = size;
7425        if width == 0 || height == 0 || width > target.width || height > target.height {
7426            return false;
7427        }
7428        let Some(source_right) = source_origin.0.checked_add(width) else {
7429            return false;
7430        };
7431        let Some(source_bottom) = source_origin.1.checked_add(height) else {
7432            return false;
7433        };
7434        if source_right > source.width || source_bottom > source.height {
7435            return false;
7436        }
7437
7438        self.recorder.encoder().copy_texture_to_texture(
7439            wgpu::TexelCopyTextureInfo {
7440                texture: source.texture(),
7441                mip_level: 0,
7442                origin: wgpu::Origin3d {
7443                    x: source_origin.0,
7444                    y: source_origin.1,
7445                    z: 0,
7446                },
7447                aspect: wgpu::TextureAspect::All,
7448            },
7449            wgpu::TexelCopyTextureInfo {
7450                texture: target.texture(),
7451                mip_level: 0,
7452                origin: wgpu::Origin3d::ZERO,
7453                aspect: wgpu::TextureAspect::All,
7454            },
7455            wgpu::Extent3d {
7456                width,
7457                height,
7458                depth_or_array_layers: 1,
7459            },
7460        );
7461        true
7462    }
7463
7464    fn shader_composite_batch_to_view(
7465        &mut self,
7466        dest_view: &wgpu::TextureView,
7467        viewport: (u32, u32),
7468        load_op: wgpu::LoadOp<wgpu::Color>,
7469        composites: &[ShaderCompositeBatchItem<'_>],
7470    ) -> bool {
7471        if composites.is_empty() {
7472            return true;
7473        }
7474        let device = self.renderer.device.clone();
7475        let encoded = self
7476            .renderer
7477            .effect_renderer
7478            .encode_shader_batch_src_over_to_view(
7479                self.recorder,
7480                &device,
7481                dest_view,
7482                viewport,
7483                load_op,
7484                composites,
7485            );
7486        if encoded {
7487            self.recorder.record_pass();
7488            self.renderer.effect_renderer.record_composite_pass();
7489            self.renderer
7490                .effect_renderer
7491                .debug_effects
7492                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
7493        }
7494        encoded
7495    }
7496
7497    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7498        &mut self,
7499        source: &OffscreenTarget,
7500        dest_view: &wgpu::TextureView,
7501        alpha: f32,
7502        load_op: wgpu::LoadOp<wgpu::Color>,
7503        scissor: Option<(u32, u32, u32, u32)>,
7504        rounded_mask: Option<RoundedCompositeMask>,
7505        blend_mode: BlendMode,
7506        dest_viewport: Option<(f32, f32, f32, f32)>,
7507        sample_mode: CompositeSampleMode,
7508    ) {
7509        let device = self.renderer.device.clone();
7510        {
7511            self.renderer
7512                .effect_renderer
7513                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
7514                    self.recorder,
7515                    &device,
7516                    source,
7517                    dest_view,
7518                    alpha,
7519                    load_op,
7520                    scissor,
7521                    rounded_mask,
7522                    supported_blend_mode(blend_mode),
7523                    dest_viewport,
7524                    sample_mode,
7525                );
7526        }
7527        self.recorder.record_pass();
7528        self.renderer.effect_renderer.record_composite_pass();
7529    }
7530
7531    fn apply_effect_and_composite_to_view(
7532        &mut self,
7533        source: &OffscreenTarget,
7534        effect: &RenderEffect,
7535        effect_rect: [f32; 4],
7536        dest_view: &wgpu::TextureView,
7537        alpha: f32,
7538        load_op: wgpu::LoadOp<wgpu::Color>,
7539        scissor: Option<(u32, u32, u32, u32)>,
7540        blend_mode: BlendMode,
7541        dest_viewport: Option<(f32, f32, f32, f32)>,
7542        sample_mode: CompositeSampleMode,
7543    ) -> Result<(), String> {
7544        self.record_effect_composite(
7545            source,
7546            effect,
7547            effect_rect,
7548            dest_view,
7549            alpha,
7550            load_op,
7551            scissor,
7552            blend_mode,
7553            dest_viewport,
7554            sample_mode,
7555        )
7556    }
7557
7558    fn apply_shader_and_composite_to_view(
7559        &mut self,
7560        source: &OffscreenTarget,
7561        shader: &RuntimeShader,
7562        effect_rect: [f32; 4],
7563        dest_view: &wgpu::TextureView,
7564        alpha: f32,
7565        load_op: wgpu::LoadOp<wgpu::Color>,
7566        scissor: Option<(u32, u32, u32, u32)>,
7567        blend_mode: BlendMode,
7568        dest_viewport: Option<(f32, f32, f32, f32)>,
7569        sample_mode: CompositeSampleMode,
7570    ) {
7571        self.record_shader_composite(
7572            source,
7573            shader,
7574            effect_rect,
7575            dest_view,
7576            alpha,
7577            load_op,
7578            scissor,
7579            blend_mode,
7580            dest_viewport,
7581            sample_mode,
7582        );
7583    }
7584
7585    fn apply_shader_and_composite_to_view_projective(
7586        &mut self,
7587        source: &OffscreenTarget,
7588        shader: &RuntimeShader,
7589        effect_rect: [f32; 4],
7590        dest_view: &wgpu::TextureView,
7591        viewport: (u32, u32),
7592        source_size: (f32, f32),
7593        inverse_matrix: [[f32; 3]; 3],
7594        dest_bounds: [[f32; 2]; 4],
7595        alpha: f32,
7596        load_op: wgpu::LoadOp<wgpu::Color>,
7597        scissor: Option<(u32, u32, u32, u32)>,
7598        blend_mode: BlendMode,
7599        sample_mode: CompositeSampleMode,
7600    ) {
7601        self.record_shader_projective_composite(
7602            source,
7603            shader,
7604            effect_rect,
7605            dest_view,
7606            viewport,
7607            source_size,
7608            inverse_matrix,
7609            dest_bounds,
7610            alpha,
7611            load_op,
7612            scissor,
7613            blend_mode,
7614            sample_mode,
7615        );
7616    }
7617
7618    fn apply_effect_and_composite_to_view_projective(
7619        &mut self,
7620        source: &OffscreenTarget,
7621        effect: &RenderEffect,
7622        effect_rect: [f32; 4],
7623        dest_view: &wgpu::TextureView,
7624        viewport: (u32, u32),
7625        source_size: (f32, f32),
7626        inverse_matrix: [[f32; 3]; 3],
7627        dest_bounds: [[f32; 2]; 4],
7628        alpha: f32,
7629        load_op: wgpu::LoadOp<wgpu::Color>,
7630        scissor: Option<(u32, u32, u32, u32)>,
7631        blend_mode: BlendMode,
7632        sample_mode: CompositeSampleMode,
7633    ) -> Result<(), String> {
7634        self.record_effect_projective_composite(
7635            source,
7636            effect,
7637            effect_rect,
7638            dest_view,
7639            viewport,
7640            source_size,
7641            inverse_matrix,
7642            dest_bounds,
7643            alpha,
7644            load_op,
7645            scissor,
7646            blend_mode,
7647            sample_mode,
7648        )
7649    }
7650
7651    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
7652        self.renderer.supports_render_effect(effect)
7653    }
7654
7655    fn warn_unsupported_effect_once(&self) {
7656        self.renderer.warning_state.warn_unsupported_effect_once();
7657    }
7658
7659    fn record_layer_cache_miss(&self, width: u32, height: u32) {
7660        self.renderer
7661            .frame_stats
7662            .record_layer_cache_miss(width, height);
7663    }
7664
7665    fn record_isolated_layer_render(
7666        &self,
7667        width: u32,
7668        height: u32,
7669        node_id: Option<NodeId>,
7670        logical_rect: Rect,
7671        requirements: SurfaceRequirementSet,
7672    ) {
7673        self.renderer.frame_stats.record_isolated_layer_render(
7674            width,
7675            height,
7676            node_id,
7677            logical_rect,
7678            requirements.into(),
7679        );
7680    }
7681}
7682
7683impl GpuRenderer {
7684    pub fn render(
7685        &mut self,
7686        view: &wgpu::TextureView,
7687        width: u32,
7688        height: u32,
7689        packet: FramePacket,
7690        surface_epoch: u64,
7691        returns: &mut RenderReturns,
7692    ) -> Result<(), String> {
7693        // Packet validity gate — BEFORE consume_replay_ops and any
7694        // encoding. A packet built against another renderer instance,
7695        // another surface configuration, or another viewport is cancelled
7696        // whole: its buffers travel back through `returns` for re-queue
7697        // and recycling, and nothing of it reaches the GPU.
7698        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
7699            Some(CancelReason::RendererEpoch)
7700        } else if packet.surface_epoch != surface_epoch {
7701            Some(CancelReason::SurfaceEpoch)
7702        } else if packet.viewport != (width, height) {
7703            Some(CancelReason::Viewport)
7704        } else {
7705            None
7706        };
7707        if let Some(reason) = cancel_reason {
7708            return Self::cancel_packet(packet, reason, returns);
7709        }
7710        returns.frame_id = packet.frame_id;
7711        log::trace!("🎨 Rendering graph to {}x{}", width, height);
7712        let render_start = Instant::now();
7713
7714        #[cfg(target_arch = "wasm32")]
7715        {
7716            self.wasm_uniform_batch_cursor = 0;
7717            self.wasm_shape_batch_cursor = 0;
7718            self.wasm_image_batch_cursor = 0;
7719        }
7720        #[cfg(not(target_arch = "wasm32"))]
7721        {
7722            self.retained_glyph_uniform_cursor = 0;
7723            // Transient rim meshes live for exactly one frame: the scratch
7724            // restarts here and every fused chunk appends after the region
7725            // already uploaded (the GPU buffers themselves are fixed-capacity
7726            // and persist).
7727            self.rim_mesh_vertices.clear();
7728            self.rim_mesh_indices.clear();
7729            self.rim_mesh_uploaded_vertices = 0;
7730            self.rim_mesh_uploaded_indices = 0;
7731            if fill_area_diag_enabled() {
7732                self.fill_area_diag.reset_frame(width, height);
7733            }
7734            // One engagement per frame: the first fused partition carrying
7735            // the frame's opaque clear consumes this.
7736            self.static_span.armed = true;
7737        }
7738
7739        // Producer-side text layout cache size, carried by the packet — the
7740        // present call tree holds no text layout state, and no layout runs
7741        // between packet build and the stats block below.
7742        let text_cache_len = packet.text_cache_len;
7743        let result = self.render_graph(view, packet, returns);
7744        let after_graph = Instant::now();
7745        self.flush_deferred_offscreen_releases();
7746        #[cfg(not(target_arch = "wasm32"))]
7747        {
7748            if fill_area_diag_enabled() {
7749                // Effect/composite fill accumulated during the graph walk
7750                // lives in the effect renderer's own cells; fold it into
7751                // this frame before the window closes over it.
7752                let (composite_px2, offscreen_px2) = self.effect_renderer.take_fill_diag_fill_px2();
7753                self.fill_area_diag
7754                    .add_effect_fill(composite_px2, offscreen_px2);
7755                self.fill_area_diag.finish_frame(width, height);
7756            }
7757        }
7758
7759        #[cfg(target_arch = "wasm32")]
7760        {
7761            const WASM_BATCH_POOL_MARGIN: usize = 4;
7762            self.wasm_uniform_batches.truncate(
7763                self.wasm_uniform_batch_cursor
7764                    .saturating_add(WASM_BATCH_POOL_MARGIN),
7765            );
7766            self.wasm_shape_batches.truncate(
7767                self.wasm_shape_batch_cursor
7768                    .saturating_add(WASM_BATCH_POOL_MARGIN),
7769            );
7770            self.wasm_image_batches.truncate(
7771                self.wasm_image_batch_cursor
7772                    .saturating_add(WASM_BATCH_POOL_MARGIN),
7773            );
7774        }
7775        self.staged_uploads
7776            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
7777
7778        self.layer_surface_cache.finish_frame(&self.frame_stats);
7779        #[cfg(not(target_arch = "wasm32"))]
7780        self.retained_bundle_cache.end_frame();
7781
7782        self.frame_stats.offscreen_pool_size.set(
7783            self.effect_renderer
7784                .retained_offscreen_count()
7785                .saturating_add(self.frame_graph_executor.retained_texture_count())
7786                as u32,
7787        );
7788        self.frame_stats.offscreen_pool_bytes.set(
7789            (self.effect_renderer.retained_offscreen_bytes() as u64)
7790                .saturating_add(self.frame_graph_executor.retained_texture_bytes()),
7791        );
7792        self.frame_stats
7793            .text_pool_size
7794            .set(self.text_image_cache.len() as u32);
7795        self.frame_stats
7796            .image_cache_size
7797            .set(self.image_texture_cache.len() as u32);
7798        self.frame_stats.text_cache_size.set(text_cache_len as u32);
7799        self.effect_renderer
7800            .merge_and_reset_debug_counters(&self.frame_stats);
7801        self.frame_graph_executor.reset_upload_allocators();
7802        let snapshot = self.frame_stats.snapshot();
7803        self.last_frame_stats = Some(snapshot);
7804        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7805        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
7806        self.frame_stats.maybe_print_snapshot(
7807            snapshot,
7808            &mut self.frame_count,
7809            self.gpu_stats_enabled,
7810        );
7811        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
7812            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
7813        }
7814        self.frame_stats.reset();
7815        let after_stats = Instant::now();
7816        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
7817            log::warn!(
7818                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
7819                instant_ms(render_start, after_graph),
7820                instant_ms(after_graph, after_stats),
7821            );
7822        }
7823        if result.is_ok() {
7824            // Only a draw that actually ran may report `Presented`; an
7825            // errored draw leaves the default `NotRun`.
7826            returns.outcome = PresentOutcome::Presented;
7827        }
7828        result
7829    }
7830
7831    /// Refuses a packet whole, before any encoding: every buffer it
7832    /// carries travels back through `returns` — the direct scene for the
7833    /// producer pool, the unconsumed replay plan for the planner to
7834    /// re-queue (its releases name still-live store slots; dropping them
7835    /// would leak pool ids forever). A cancel is a protocol outcome, not a
7836    /// draw error, so the render call returns `Ok(())`.
7837    fn cancel_packet(
7838        packet: FramePacket,
7839        reason: CancelReason,
7840        returns: &mut RenderReturns,
7841    ) -> Result<(), String> {
7842        let FramePacket {
7843            frame_id,
7844            viewport: _,
7845            renderer_epoch: _,
7846            surface_epoch: _,
7847            root_scale: _,
7848            root,
7849            overlay: _,
7850            replay,
7851            text_cache_len: _,
7852        } = packet;
7853        match root {
7854            PacketRoot::Direct(root) => {
7855                // Destructure: the scene buffers return to the producer
7856                // pool; the rest of the collected layer drops. A Direct
7857                // packet's replay plan came from the planner and must go
7858                // back to it unconsumed — a Surface packet only ever
7859                // carries the empty default plan, which has nothing to
7860                // reclaim.
7861                returns.scene = Some(root.scene);
7862                #[cfg(not(target_arch = "wasm32"))]
7863                {
7864                    returns.cancelled_replay = Some(replay);
7865                }
7866            }
7867            PacketRoot::Surface(_) => {}
7868        }
7869        #[cfg(target_arch = "wasm32")]
7870        let _ = replay;
7871        returns.ack = None;
7872        returns.frame_id = frame_id;
7873        returns.outcome = PresentOutcome::Cancelled(reason);
7874        Ok(())
7875    }
7876
7877    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
7878        self.last_frame_stats
7879    }
7880
7881    pub fn needs_frame_warmup(&self) -> bool {
7882        self.pending_frame_warmup_frames > 0
7883    }
7884
7885    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
7886        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
7887        DebugCpuAllocationStats {
7888            scene_graph_node_count: 0,
7889            scene_graph_heap_bytes: 0,
7890            scene_hits_len: 0,
7891            scene_hits_cap: 0,
7892            scene_node_index_len: 0,
7893            scene_node_index_cap: 0,
7894            text_renderer_pool_len: self.text_image_cache.len(),
7895            text_renderer_pool_cap: self.text_image_cache.cap().get(),
7896            swash_image_cache_len: 0,
7897            swash_image_cache_cap: 0,
7898            swash_outline_cache_len: 0,
7899            swash_outline_cache_cap: 0,
7900            image_texture_cache_len: self.image_texture_cache.len(),
7901            image_texture_cache_cap: self.image_texture_cache.cap().get(),
7902            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
7903            scratch_gradients_cap: self.scratch_gradients.capacity(),
7904            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
7905            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
7906            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
7907            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
7908            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
7909            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
7910            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
7911            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
7912            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
7913            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
7914            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
7915            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
7916            // The producer frontend owns the only lowering-memo pair since
7917            // step 6b; the present backend contributes nothing.
7918            layer_surface_rect_cache_len: 0,
7919            layer_surface_rect_cache_cap: 0,
7920            layer_surface_requirements_cache_len: 0,
7921            layer_surface_requirements_cache_cap: 0,
7922            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
7923            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
7924        }
7925    }
7926
7927    pub fn render_to_rgba_pixels(
7928        &mut self,
7929        width: u32,
7930        height: u32,
7931        packet: FramePacket,
7932        surface_epoch: u64,
7933        returns: &mut RenderReturns,
7934    ) -> Result<Vec<u8>, String> {
7935        if width == 0 || height == 0 {
7936            return Err("Screenshot size must be non-zero".to_string());
7937        }
7938
7939        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
7940            label: Some("Screenshot Output Texture"),
7941            size: wgpu::Extent3d {
7942                width,
7943                height,
7944                depth_or_array_layers: 1,
7945            },
7946            mip_level_count: 1,
7947            sample_count: 1,
7948            dimension: wgpu::TextureDimension::D2,
7949            format: self.surface_format,
7950            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
7951            view_formats: &[],
7952        });
7953        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
7954
7955        self.render(&output_view, width, height, packet, surface_epoch, returns)?;
7956
7957        let bytes_per_pixel = 4u32;
7958        let unpadded_bytes_per_row = width
7959            .checked_mul(bytes_per_pixel)
7960            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
7961        let padded_bytes_per_row =
7962            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
7963        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
7964
7965        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
7966            label: Some("Screenshot Readback Buffer"),
7967            size: output_buffer_size,
7968            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
7969            mapped_at_creation: false,
7970        });
7971
7972        let device = self.device.clone();
7973        let queue = self.queue.clone();
7974        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
7975        let source = graph.import_surface("screenshot-copy-source");
7976        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
7977            context.encoder.copy_texture_to_buffer(
7978                wgpu::TexelCopyTextureInfo {
7979                    texture: &output_texture,
7980                    mip_level: 0,
7981                    origin: wgpu::Origin3d::ZERO,
7982                    aspect: wgpu::TextureAspect::All,
7983                },
7984                wgpu::TexelCopyBufferInfo {
7985                    buffer: &output_buffer,
7986                    layout: wgpu::TexelCopyBufferLayout {
7987                        offset: 0,
7988                        bytes_per_row: Some(padded_bytes_per_row),
7989                        rows_per_image: Some(height),
7990                    },
7991                },
7992                wgpu::Extent3d {
7993                    width,
7994                    height,
7995                    depth_or_array_layers: 1,
7996                },
7997            );
7998            Ok(())
7999        });
8000        let mut executor = std::mem::take(&mut self.frame_graph_executor);
8001        let execution = executor.execute_recorded_graph(&device, &queue, graph);
8002        self.frame_graph_executor = executor;
8003        let execution = execution.map_err(|error| error.to_string())?;
8004        let submission_index = execution.submission;
8005        let copy_stats = execution.stats;
8006        self.last_frame_stats = self
8007            .last_frame_stats
8008            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
8009
8010        let buffer_slice = output_buffer.slice(..);
8011        let (tx, rx) = mpsc::channel();
8012        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
8013            let _ = tx.send(result);
8014        });
8015        let _ = self.device.poll(wgpu::PollType::Wait {
8016            submission_index: Some(submission_index),
8017            timeout: None,
8018        });
8019
8020        match rx.recv_timeout(Duration::from_secs(3)) {
8021            Ok(Ok(())) => {}
8022            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
8023            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
8024        }
8025
8026        let mapped = buffer_slice.get_mapped_range();
8027        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
8028
8029        let src_row_len = padded_bytes_per_row as usize;
8030        let dst_row_len = unpadded_bytes_per_row as usize;
8031        for row in 0..height as usize {
8032            let src_offset = row * src_row_len;
8033            let dst_offset = row * dst_row_len;
8034            pixels[dst_offset..dst_offset + dst_row_len]
8035                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
8036        }
8037        drop(mapped);
8038        output_buffer.unmap();
8039
8040        self.convert_surface_pixels_to_rgba(&mut pixels)?;
8041        Ok(pixels)
8042    }
8043
8044    fn render_graph(
8045        &mut self,
8046        surface_view: &wgpu::TextureView,
8047        packet: FramePacket,
8048        returns: &mut RenderReturns,
8049    ) -> Result<(), String> {
8050        let device = self.device.clone();
8051        let queue = self.queue.clone();
8052        let graph_start = Instant::now();
8053
8054        #[cfg(not(target_arch = "wasm32"))]
8055        {
8056            let mut executor = std::mem::take(&mut self.frame_graph_executor);
8057            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
8058            let surface = frame_graph.import_surface("renderer-surface");
8059            frame_graph.add_fallible_recorded_command_pass(
8060                Some("Renderer Frame Pass"),
8061                &[],
8062                &[surface],
8063                |frame_encoder| {
8064                    self.render_graph_recorded(surface_view, packet, returns, frame_encoder)
8065                },
8066            );
8067            let after_build = Instant::now();
8068            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
8069            let after_execute = Instant::now();
8070            self.frame_graph_executor = executor;
8071            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
8072                log::warn!(
8073                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
8074                    instant_ms(graph_start, after_build),
8075                    instant_ms(after_build, after_execute),
8076                );
8077            }
8078
8079            match execution {
8080                Ok(execution) => {
8081                    if execution.stats.pass_count > 0 {
8082                        self.frame_stats.record_command_stats(execution.stats);
8083                    }
8084                    Ok(())
8085                }
8086                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
8087                Err(error) => Err(error.to_string()),
8088            }
8089        }
8090
8091        #[cfg(target_arch = "wasm32")]
8092        {
8093            let mut executor = std::mem::take(&mut self.frame_graph_executor);
8094            let (result, execution) = {
8095                let mut frame_encoder =
8096                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
8097                let initial_pass_count = frame_encoder.recorded_pass_count();
8098                let result =
8099                    self.render_graph_recorded(surface_view, packet, returns, &mut frame_encoder);
8100                let execution =
8101                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
8102                        Some(frame_encoder.finish())
8103                    } else {
8104                        None
8105                    };
8106                (result, execution)
8107            };
8108            let after_execute = Instant::now();
8109            self.frame_graph_executor = executor;
8110            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
8111                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
8112            }
8113            if let Some(execution) = execution {
8114                self.frame_stats.record_command_stats(execution.stats);
8115            }
8116            result
8117        }
8118    }
8119
8120    fn render_graph_recorded<C: FrameCommandRecorder>(
8121        &mut self,
8122        surface_view: &wgpu::TextureView,
8123        packet: FramePacket,
8124        returns: &mut RenderReturns,
8125        frame_encoder: &mut C,
8126    ) -> Result<(), String> {
8127        let recorded_start = Instant::now();
8128
8129        // Present-side consumption of the packet's replay plan, adjacent to
8130        // packet consumption: the store honors the ops just before the
8131        // packet renders. Gated on a Direct root — a Surface packet never
8132        // touched the planner and carries the empty default plan
8133        // (generation 0), which the store must not consume: it would count
8134        // a false generation drop. The ack travels back through `returns`
8135        // and the producer applies it right after this render call —
8136        // equivalent to the in-store drain this replaces, because both
8137        // application points sit after this frame's graph build and before
8138        // the next collect, which is where the bypass gate and `feed_slots`
8139        // are read.
8140        #[cfg(not(target_arch = "wasm32"))]
8141        let mut packet = packet;
8142        #[cfg(not(target_arch = "wasm32"))]
8143        if let PacketRoot::Direct(root) = &packet.root {
8144            let ops = std::mem::take(&mut packet.replay);
8145            let (ack, recycled) = self.consume_replay_ops(
8146                ops,
8147                &root.scene.shapes,
8148                &root.scene.brushes,
8149                packet.root_scale,
8150            );
8151            returns.ack = Some((ack, recycled));
8152        }
8153
8154        let FramePacket {
8155            frame_id,
8156            viewport: (width, height),
8157            renderer_epoch: _,
8158            surface_epoch: _,
8159            root_scale,
8160            root,
8161            overlay,
8162            replay: _,
8163            text_cache_len: _,
8164        } = packet;
8165
8166        let mut backend = RecordingSurfaceBackend {
8167            renderer: self,
8168            recorder: frame_encoder,
8169        };
8170
8171        let surface_packet = match root {
8172            PacketRoot::Direct(root) => {
8173                let direct_render_start = Instant::now();
8174                let result = match execute_render_root_direct(
8175                    &mut backend,
8176                    surface_view,
8177                    *root,
8178                    width,
8179                    height,
8180                    root_scale,
8181                    wgpu::LoadOp::Clear(CLEAR_COLOR),
8182                ) {
8183                    // Return the packet's scene buffers to the producer pool
8184                    // in BOTH arms — for a heavy animated frame they are
8185                    // megabytes of Vec, and an errored draw must not leak
8186                    // them.
8187                    Ok(scene) => {
8188                        returns.scene = Some(scene);
8189                        Ok(())
8190                    }
8191                    Err((error, scene)) => {
8192                        returns.scene = Some(scene);
8193                        Err(error)
8194                    }
8195                };
8196                if result.is_ok() {
8197                    if let Some(overlay) = overlay {
8198                        Self::render_overlay_packet(
8199                            &mut backend,
8200                            surface_view,
8201                            overlay,
8202                            width,
8203                            height,
8204                            root_scale,
8205                        )?;
8206                    }
8207                }
8208                let after_direct_render = Instant::now();
8209                if let Some(total_ms) =
8210                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
8211                {
8212                    log::warn!(
8213                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
8214                        instant_ms(direct_render_start, after_direct_render),
8215                    );
8216                }
8217                return result;
8218            }
8219            PacketRoot::Surface(surface_packet) => surface_packet,
8220        };
8221        let after_root_collect = Instant::now();
8222
8223        let RootSurfacePacket {
8224            lowered,
8225            source,
8226            transform_to_parent,
8227            node_id,
8228            backdrop,
8229            graphics_layer,
8230            local_bounds,
8231            clip_rect,
8232            shadow_clip,
8233        } = *surface_packet;
8234        let mut lowered = lowered;
8235        lowered.source = source;
8236
8237        // The root layer's visible area is always the viewport — content
8238        // outside the screen is invisible regardless of scroll offsets or
8239        // inflated scene bounds.  Pass the viewport rect as an explicit
8240        // surface rect to prevent offscreen inflation on constrained GPUs.
8241        let viewport_rect = Rect {
8242            x: 0.0,
8243            y: 0.0,
8244            width: width as f32 / root_scale,
8245            height: height as f32 / root_scale,
8246        };
8247        let root_surface = execute_render_layer_surface(
8248            &mut backend,
8249            &mut lowered,
8250            LayerSurfaceRequest {
8251                root_scale,
8252                backdrop_underlay: None,
8253                allow_runtime_cache: false,
8254                logical_rect_override: Some(viewport_rect),
8255                capture_clip_override: None,
8256                activates_nested_capture: false,
8257                translation_context: TranslationRenderContext::default(),
8258            },
8259        )?;
8260        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
8261        let root_dest_quad = scaled_quad(root_quad, root_scale);
8262
8263        let needs_root_composite_target =
8264            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
8265
8266        if needs_root_composite_target {
8267            let composite_target = backend.acquire_frame_surface(width, height);
8268            backend.clear_target_view_with_load_op(
8269                &composite_target.view,
8270                wgpu::LoadOp::Clear(CLEAR_COLOR),
8271            );
8272
8273            if let Some(backdrop) = &backdrop {
8274                execute_apply_backdrop_layer_to_target(
8275                    &mut backend,
8276                    &composite_target,
8277                    &BackdropLayer {
8278                        node_id,
8279                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
8280                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
8281                        snap_anchor: None,
8282                        effect: backdrop.clone(),
8283                        z_index: 0,
8284                    },
8285                    None,
8286                    width,
8287                    height,
8288                    root_scale,
8289                    None,
8290                )?;
8291            }
8292
8293            let mut root_shadow_scene = CompositorScene::new();
8294            let root_shadow_clip =
8295                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
8296            push_layer_shadow(
8297                &mut root_shadow_scene,
8298                &graphics_layer,
8299                local_bounds,
8300                quad_bounds(transform_to_parent.map_rect(local_bounds)),
8301                root_shadow_clip,
8302            );
8303            for shadow in &root_shadow_scene.shadow_draws {
8304                backend.render_shadow_draw(
8305                    &composite_target.view,
8306                    shadow,
8307                    width,
8308                    height,
8309                    root_scale,
8310                );
8311            }
8312
8313            let composite_dest_quad =
8314                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
8315            execute_composite_surface_to_view(
8316                &mut backend,
8317                root_surface.target.target(),
8318                &composite_target.view,
8319                (width, height),
8320                composite_dest_quad,
8321                root_surface.composite_alpha,
8322                wgpu::LoadOp::Load,
8323                None,
8324                root_surface.blend_mode,
8325                root_surface.sample_mode,
8326            )?;
8327            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8328                &composite_target,
8329                surface_view,
8330                1.0,
8331                wgpu::LoadOp::Clear(CLEAR_COLOR),
8332                None,
8333                None,
8334                BlendMode::SrcOver,
8335                None,
8336                CompositeSampleMode::Linear,
8337            );
8338            backend.release_frame_surface(composite_target);
8339        } else {
8340            let composite_dest_quad =
8341                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
8342            execute_composite_surface_to_view(
8343                &mut backend,
8344                root_surface.target.target(),
8345                surface_view,
8346                (width, height),
8347                composite_dest_quad,
8348                root_surface.composite_alpha,
8349                wgpu::LoadOp::Clear(CLEAR_COLOR),
8350                None,
8351                root_surface.blend_mode,
8352                root_surface.sample_mode,
8353            )?;
8354        }
8355        backend.release_layer_surface_target(root_surface.target);
8356        if let Some(overlay) = overlay {
8357            Self::render_overlay_packet(
8358                &mut backend,
8359                surface_view,
8360                overlay,
8361                width,
8362                height,
8363                root_scale,
8364            )?;
8365        }
8366        let after_layer_render = Instant::now();
8367        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
8368            log::warn!(
8369                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
8370                instant_ms(recorded_start, after_root_collect),
8371                instant_ms(after_root_collect, after_layer_render),
8372            );
8373        }
8374        Ok(())
8375    }
8376
8377    /// Renders the producer-lowered dev overlay on top of the frame. The
8378    /// packet carries the collected overlay; the backend only validates
8379    /// that it stayed directly renderable and draws it.
8380    fn render_overlay_packet<C: FrameCommandRecorder>(
8381        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
8382        surface_view: &wgpu::TextureView,
8383        overlay: CollectedLayer,
8384        width: u32,
8385        height: u32,
8386        root_scale: f32,
8387    ) -> Result<(), String> {
8388        if !overlay.child_layers.is_empty()
8389            || !root_direct_scene_events_are_supported(&overlay.scene)
8390            || !direct_root_child_underlays_are_supported(&overlay)
8391        {
8392            return Err("dev overlay graph must stay directly renderable".to_string());
8393        }
8394        execute_render_root_direct(
8395            backend,
8396            surface_view,
8397            overlay,
8398            width,
8399            height,
8400            root_scale,
8401            wgpu::LoadOp::Load,
8402        )
8403        .map(|_overlay_scene| ())
8404        .map_err(|(error, _overlay_scene)| error)
8405    }
8406
8407    #[allow(clippy::too_many_arguments)]
8408    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
8409        &mut self,
8410        frame_encoder: &mut C,
8411        target_view: &wgpu::TextureView,
8412        ordered_items: &[(usize, SegmentDrawItem)],
8413        composites: &[(usize, CompositeBatchItem<'_>)],
8414        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
8415        shapes: &[DrawShape],
8416        brushes: &[Brush],
8417        images: &[ImageDraw],
8418        texts: &[TextDraw],
8419        shadow_draws: &[ShadowDraw],
8420        retained_draws: &[RetainedDraw],
8421        initial_load_op: wgpu::LoadOp<wgpu::Color>,
8422        width: u32,
8423        height: u32,
8424        root_scale: f32,
8425    ) -> Result<SegmentCommandEncodeOutcome, String> {
8426        let mut first_batch = true;
8427        for command in
8428            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
8429        {
8430            match command {
8431                SegmentRenderCommand::DrawChunk(chunk) => {
8432                    let load_op = if first_batch {
8433                        initial_load_op
8434                    } else {
8435                        wgpu::LoadOp::Load
8436                    };
8437                    let outcome = self.render_segment_draw_chunk(
8438                        frame_encoder,
8439                        target_view,
8440                        ordered_items,
8441                        composites,
8442                        shader_composites,
8443                        shapes,
8444                        brushes,
8445                        images,
8446                        texts,
8447                        retained_draws,
8448                        chunk,
8449                        width,
8450                        height,
8451                        root_scale,
8452                        load_op,
8453                    )?;
8454                    if outcome.rendered_any {
8455                        frame_encoder.record_passes(outcome.pass_count);
8456                        first_batch = false;
8457                    }
8458                }
8459                SegmentRenderCommand::Shadow(index) => {
8460                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
8461                        {
8462                            let _clear = frame_encoder.encoder().begin_render_pass(
8463                                &wgpu::RenderPassDescriptor {
8464                                    label: Some("Shadow Pre-Clear"),
8465                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8466                                        view: target_view,
8467                                        resolve_target: None,
8468                                        depth_slice: None,
8469                                        ops: wgpu::Operations {
8470                                            load: initial_load_op,
8471                                            store: wgpu::StoreOp::Store,
8472                                        },
8473                                    })],
8474                                    depth_stencil_attachment: None,
8475                                    timestamp_writes: None,
8476                                    occlusion_query_set: None,
8477                                    multiview_mask: None,
8478                                },
8479                            );
8480                        }
8481                        frame_encoder.record_pass();
8482                        first_batch = false;
8483                    }
8484                    let pass_count_before = frame_encoder.recorded_pass_count();
8485                    self.encode_shadow_draw(
8486                        frame_encoder,
8487                        target_view,
8488                        &shadow_draws[index],
8489                        width,
8490                        height,
8491                        root_scale,
8492                    );
8493                    if frame_encoder.recorded_pass_count() > pass_count_before {
8494                        first_batch = false;
8495                    }
8496                }
8497            }
8498        }
8499        Ok(SegmentCommandEncodeOutcome { first_batch })
8500    }
8501
8502    #[cfg(not(target_arch = "wasm32"))]
8503    #[allow(clippy::too_many_arguments)]
8504    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
8505        &mut self,
8506        frame_encoder: &mut C,
8507        target_view: &wgpu::TextureView,
8508        ordered_items: &[(usize, SegmentDrawItem)],
8509        composites: &[(usize, CompositeBatchItem<'_>)],
8510        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
8511        shapes: &[DrawShape],
8512        brushes: &[Brush],
8513        images: &[ImageDraw],
8514        texts: &[TextDraw],
8515        retained_draws: &[RetainedDraw],
8516        chunk: &SegmentDrawChunkPlan,
8517        width: u32,
8518        height: u32,
8519        root_scale: f32,
8520        load_op: wgpu::LoadOp<wgpu::Color>,
8521    ) -> Result<Option<SegmentRenderOutcome>, String> {
8522        let Some(partitions) = native_segment_fusion_partitions(
8523            ordered_items,
8524            shapes,
8525            brushes,
8526            chunk,
8527            self.shape_batch_limits,
8528        )?
8529        else {
8530            return Ok(None);
8531        };
8532
8533        let mut rendered_any = false;
8534        let mut pass_count = 0_u32;
8535        let mut next_load_op = load_op;
8536        for partition in partitions {
8537            let outcome = self.render_segment_draw_chunk_fused_native_partition(
8538                frame_encoder,
8539                target_view,
8540                ordered_items,
8541                composites,
8542                shader_composites,
8543                shapes,
8544                brushes,
8545                images,
8546                texts,
8547                retained_draws,
8548                &partition.chunk,
8549                partition.budget,
8550                width,
8551                height,
8552                root_scale,
8553                next_load_op,
8554            )?;
8555            if outcome.rendered_any {
8556                rendered_any = true;
8557                pass_count = pass_count.saturating_add(outcome.pass_count);
8558                next_load_op = wgpu::LoadOp::Load;
8559            }
8560        }
8561
8562        Ok(Some(SegmentRenderOutcome {
8563            rendered_any,
8564            pass_count,
8565        }))
8566    }
8567
8568    #[cfg(not(target_arch = "wasm32"))]
8569    #[allow(clippy::too_many_arguments)]
8570    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
8571        &mut self,
8572        frame_encoder: &mut C,
8573        target_view: &wgpu::TextureView,
8574        ordered_items: &[(usize, SegmentDrawItem)],
8575        composites: &[(usize, CompositeBatchItem<'_>)],
8576        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
8577        shapes: &[DrawShape],
8578        brushes: &[Brush],
8579        images: &[ImageDraw],
8580        texts: &[TextDraw],
8581        retained_draws: &[RetainedDraw],
8582        chunk: &SegmentDrawChunkPlan,
8583        budget: NativeSegmentFusionBudget,
8584        width: u32,
8585        height: u32,
8586        root_scale: f32,
8587        load_op: wgpu::LoadOp<wgpu::Color>,
8588    ) -> Result<SegmentRenderOutcome, String> {
8589        let partition_start = Instant::now();
8590        let mut staged_uploads = self.take_staged_uploads();
8591        staged_uploads.clear();
8592        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
8593        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
8594        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
8595        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
8596        // Moved out like the scratch vecs: the span blit borrows the cached
8597        // texture across the render pass while `self` stays mutably usable.
8598        let mut span_cache = std::mem::take(&mut self.static_span);
8599
8600        image_vertices.clear();
8601        image_indices.clear();
8602        image_cmds.clear();
8603        glyph_cmds.clear();
8604
8605        let result = (|| {
8606            let viewport = ViewportUniformParams {
8607                width,
8608                height,
8609                offset: [0.0, 0.0],
8610            };
8611            self.prewarm_offscreen_text_glyph_draws_in_chunk(
8612                ordered_items,
8613                texts,
8614                chunk,
8615                viewport,
8616                root_scale,
8617                &mut staged_uploads,
8618                &mut image_vertices,
8619                &mut image_indices,
8620                &mut glyph_cmds,
8621            )?;
8622            let mut shape_refs = Vec::with_capacity(budget.shape_count);
8623            for batch in chunk.iter() {
8624                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
8625                    continue;
8626                };
8627                for (_, item) in &ordered_items[start..end] {
8628                    let SegmentDrawItem::Shape(shape_index) = item else {
8629                        return Err(format!(
8630                            "shape batch contains non-shape draw item: {item:?}"
8631                        ));
8632                    };
8633                    shape_refs.push(&shapes[*shape_index]);
8634                }
8635            }
8636            let after_shape_refs = Instant::now();
8637
8638            let mut direct_shape_uploads = StagedBufferUploads::default();
8639            let mut shape_upload_base = 0u64;
8640            if !shape_refs.is_empty() {
8641                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
8642                    frame_encoder,
8643                    shape_refs.iter().copied(),
8644                    brushes,
8645                    root_scale,
8646                    viewport,
8647                    &mut direct_shape_uploads,
8648                ) else {
8649                    return Err(
8650                        "native fused segment shape preparation produced no draw batch".to_string(),
8651                    );
8652                };
8653                shape_upload_base = upload_base;
8654            }
8655            let after_shape_prepare = Instant::now();
8656
8657            // Opaque static leading-span cache: decide once per frame, on
8658            // the partition carrying the frame's opaque clear, whether the
8659            // leading run of converted records matches the cached span
8660            // composite (skip them, blit instead), repeated byte-identically
8661            // from last frame (draw live, then capture), or neither.
8662            let first_batch_info = match chunk.batches.first() {
8663                Some(&SegmentBatchPlan::Shape {
8664                    start,
8665                    end,
8666                    blend_mode,
8667                }) => {
8668                    let mut has_gradient = false;
8669                    for (_, item) in &ordered_items[start..end] {
8670                        if let SegmentDrawItem::Shape(shape_index) = item {
8671                            has_gradient |=
8672                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
8673                        }
8674                    }
8675                    Some((end - start, blend_mode, has_gradient))
8676                }
8677                _ => None,
8678            };
8679            let span_decision = span_cache.engage(
8680                load_op,
8681                first_batch_info,
8682                width,
8683                height,
8684                &self.scratch_shape_data,
8685                &self.scratch_gradients,
8686            );
8687            let span_skip = match span_decision {
8688                StaticSpanDecision::Hit { skip } => {
8689                    if fill_area_diag_enabled() {
8690                        // The skipped quads were counted at batch prepare;
8691                        // the replacing blit is an effect-renderer
8692                        // composite, which the instrument's policy does not
8693                        // count.
8694                        self.fill_area_diag
8695                            .note_static_span_skip(&self.scratch_shape_data[..skip]);
8696                    }
8697                    skip
8698                }
8699                _ => 0,
8700            };
8701
8702            // Transient rim band meshes: scan the freshly converted shapes
8703            // (still in `scratch_shape_data` after
8704            // `prepare_shapes_batch_direct`) for huge circle rims and give
8705            // each a band mesh covering ring ± AA margin instead of its full
8706            // bounding quad. Kill switch read once per chunk; the mesh
8707            // pipeline exists in storage mode only and blends SrcOver only,
8708            // hence the two extra gates at the batch arm below.
8709            let rim_mesh_on = rim_mesh_enabled();
8710            let mut chunk_rims: Vec<RimDraw> = Vec::new();
8711
8712            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
8713            let mut shape_cursor = 0_u32;
8714            let mut composite_cursor = 0usize;
8715            let mut shader_composite_cursor = 0usize;
8716            for (batch_index, batch) in chunk.iter().enumerate() {
8717                match batch {
8718                    SegmentBatchPlan::Shape {
8719                        start,
8720                        end,
8721                        blend_mode,
8722                    } => {
8723                        let mut has_gradient = false;
8724                        for (_, item) in &ordered_items[start..end] {
8725                            let SegmentDrawItem::Shape(shape_index) = item else {
8726                                return Err(format!(
8727                                    "shape batch contains non-shape draw item: {item:?}"
8728                                ));
8729                            };
8730                            has_gradient |=
8731                                shape_gradient_stop_count(&shapes[*shape_index], brushes) > 0;
8732                        }
8733                        // A span hit skips the leading shapes of the FIRST
8734                        // batch only: they stay in the upload (indices of
8735                        // everything after them are untouched) but the draw
8736                        // range starts past them.
8737                        let skip = if batch_index == 0 { span_skip } else { 0 };
8738                        let shape_count = end - start;
8739                        if shape_count > 0 {
8740                            if rim_mesh_on
8741                                && self.instanced_quads.is_some()
8742                                && blend_mode == BlendMode::SrcOver
8743                            {
8744                                for offset in skip..shape_count {
8745                                    // The index `vs_mesh` reads into the
8746                                    // storage shape array: position within
8747                                    // the whole fused upload (shape_refs
8748                                    // order == scratch_shape_data order).
8749                                    let global_index = shape_cursor + offset as u32;
8750                                    let converted = &self.scratch_shape_data[global_index as usize];
8751                                    let Some(band) = rim_mesh_band(converted) else {
8752                                        continue;
8753                                    };
8754                                    let vertex_mark = self.rim_mesh_vertices.len();
8755                                    let index_mark = self.rim_mesh_indices.len();
8756                                    if emit_arc_band_mesh(
8757                                        converted,
8758                                        global_index,
8759                                        &band,
8760                                        &mut self.rim_mesh_vertices,
8761                                        &mut self.rim_mesh_indices,
8762                                    )
8763                                    .is_none()
8764                                    {
8765                                        // Nothing emitted (fully clipped) —
8766                                        // the quad path draws it as today.
8767                                        self.rim_mesh_vertices.truncate(vertex_mark);
8768                                        self.rim_mesh_indices.truncate(index_mark);
8769                                        continue;
8770                                    }
8771                                    if self.rim_mesh_vertices.len() > RIM_MESH_VERTEX_CAPACITY
8772                                        || self.rim_mesh_indices.len() > RIM_MESH_INDEX_CAPACITY
8773                                    {
8774                                        // Whole-rim rollback, never a
8775                                        // truncation: a partial band would
8776                                        // break the containment invariant.
8777                                        self.rim_mesh_vertices.truncate(vertex_mark);
8778                                        self.rim_mesh_indices.truncate(index_mark);
8779                                        rim_mesh_capacity_warn();
8780                                        continue;
8781                                    }
8782                                    chunk_rims.push(RimDraw {
8783                                        shape_index: global_index,
8784                                        first_index: index_mark as u32,
8785                                        index_count: (self.rim_mesh_indices.len() - index_mark)
8786                                            as u32,
8787                                    });
8788                                    if fill_area_diag_enabled() {
8789                                        self.fill_area_diag.note_rim_mesh(
8790                                            converted,
8791                                            triangles_shoelace_area(
8792                                                &self.rim_mesh_vertices,
8793                                                &self.rim_mesh_indices[index_mark..],
8794                                            ),
8795                                        );
8796                                    }
8797                                    self.rim_meshes_emitted += 1;
8798                                    if self.rim_meshes_emitted % 600 == 1 {
8799                                        log::debug!(
8800                                            "[rim-mesh] {} rims meshed lifetime ({} verts live this frame)",
8801                                            self.rim_meshes_emitted,
8802                                            self.rim_mesh_vertices.len(),
8803                                        );
8804                                    }
8805                                }
8806                            }
8807                            if shape_count > skip {
8808                                fused_batches.push(FusedSegmentBatch::Shape {
8809                                    batch: PreparedShapeBatch {
8810                                        vertex_start: (shape_cursor + skip as u32) * 6,
8811                                        vertex_count: (shape_count - skip) as u32 * 6,
8812                                        has_gradient,
8813                                    },
8814                                    blend_mode,
8815                                });
8816                            }
8817                            shape_cursor += shape_count as u32;
8818                        }
8819                    }
8820                    SegmentBatchPlan::Image {
8821                        start,
8822                        end,
8823                        blend_mode,
8824                    } => {
8825                        let cmd_start = image_cmds.len();
8826                        for (_, item) in &ordered_items[start..end] {
8827                            let SegmentDrawItem::Image(image_index) = item else {
8828                                return Err(format!(
8829                                    "image batch contains non-image draw item: {item:?}"
8830                                ));
8831                            };
8832                            self.append_image_draw_cmd(
8833                                &images[*image_index],
8834                                viewport,
8835                                root_scale,
8836                                &mut image_vertices,
8837                                &mut image_indices,
8838                                &mut image_cmds,
8839                            )?;
8840                        }
8841                        let cmd_end = image_cmds.len();
8842                        if cmd_start < cmd_end {
8843                            fused_batches.push(FusedSegmentBatch::Image {
8844                                cmd_range: cmd_start..cmd_end,
8845                                blend_mode,
8846                            });
8847                        }
8848                    }
8849                    SegmentBatchPlan::Text { start, end } => {
8850                        let glyph_cmd_start = glyph_cmds.len();
8851                        let image_cmd_start = image_cmds.len();
8852                        let text_draws =
8853                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
8854                        if !self.append_text_glyph_draws(
8855                            text_draws,
8856                            viewport,
8857                            root_scale,
8858                            false,
8859                            &mut staged_uploads,
8860                            &mut image_vertices,
8861                            &mut image_indices,
8862                            &mut glyph_cmds,
8863                        )? {
8864                            let text_draws =
8865                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
8866                            self.append_text_image_draw_cmds(
8867                                text_draws,
8868                                viewport,
8869                                root_scale,
8870                                &mut image_vertices,
8871                                &mut image_indices,
8872                                &mut image_cmds,
8873                            )?;
8874                        }
8875                        let image_cmd_end = image_cmds.len();
8876                        let glyph_cmd_end = glyph_cmds.len();
8877                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
8878                            fused_batches.push(FusedSegmentBatch::Text {
8879                                image_cmd_range: image_cmd_start..image_cmd_end,
8880                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
8881                            });
8882                        }
8883                    }
8884                    SegmentBatchPlan::Composite { start, end } => {
8885                        for (_, item) in &ordered_items[start..end] {
8886                            if !matches!(item, SegmentDrawItem::Composite(_)) {
8887                                return Err(format!(
8888                                    "composite batch contains non-composite draw item: {item:?}"
8889                                ));
8890                            }
8891                        }
8892                        let draw_count = end - start;
8893                        if draw_count > 0 {
8894                            let draw_start = composite_cursor;
8895                            composite_cursor += draw_count;
8896                            fused_batches.push(FusedSegmentBatch::Composite {
8897                                draw_range: draw_start..composite_cursor,
8898                            });
8899                        }
8900                    }
8901                    SegmentBatchPlan::ShaderComposite { start, end } => {
8902                        for (_, item) in &ordered_items[start..end] {
8903                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
8904                                return Err(format!(
8905                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
8906                                ));
8907                            }
8908                        }
8909                        let draw_count = end - start;
8910                        if draw_count > 0 {
8911                            let draw_start = shader_composite_cursor;
8912                            shader_composite_cursor += draw_count;
8913                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
8914                                draw_range: draw_start..shader_composite_cursor,
8915                            });
8916                        }
8917                    }
8918                    SegmentBatchPlan::Retained { start, end } => {
8919                        self.stage_replay_patches(&mut staged_uploads);
8920                        for (_, item) in &ordered_items[start..end] {
8921                            let SegmentDrawItem::Retained(index) = item else {
8922                                return Err(format!(
8923                                    "retained batch contains non-retained draw item: {item:?}"
8924                                ));
8925                            };
8926                            let retained = retained_draws.get(*index).ok_or_else(|| {
8927                                format!("retained draw index {index} out of bounds")
8928                            })?;
8929                            if (*index as u32) < MAX_REPLAY_SLOTS
8930                                && self.replay_slots.slots.contains_key(&retained.slot)
8931                            {
8932                                let transform = retained.transform.with_retained_paint();
8933                                staged_uploads.stage_at(
8934                                    UploadTarget::ReplayTransform,
8935                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
8936                                    bytemuck::bytes_of(&transform),
8937                                );
8938                            }
8939                        }
8940                        if end > start {
8941                            fused_batches.push(FusedSegmentBatch::Retained {
8942                                item_range: start..end,
8943                            });
8944                        }
8945                    }
8946                }
8947            }
8948            if !chunk_rims.is_empty() {
8949                self.upload_transient_rim_meshes();
8950            }
8951            let after_batch_prepare = Instant::now();
8952
8953            if !image_indices.is_empty() {
8954                self.stage_native_image_buffers(
8955                    &mut staged_uploads,
8956                    viewport,
8957                    &image_vertices,
8958                    &image_indices,
8959                );
8960            }
8961
8962            let device = self.device.clone();
8963            let composite_items: Vec<_> = chunk
8964                .iter()
8965                .filter_map(|batch| match batch {
8966                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
8967                    _ => None,
8968                })
8969                .flat_map(|(start, end)| {
8970                    ordered_items[start..end].iter().filter_map(|(_, item)| {
8971                        let SegmentDrawItem::Composite(composite_index) = item else {
8972                            return None;
8973                        };
8974                        composites
8975                            .get(*composite_index)
8976                            .map(|(_, composite)| *composite)
8977                    })
8978                })
8979                .collect();
8980            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
8981                frame_encoder,
8982                &device,
8983                load_op,
8984                &composite_items,
8985            );
8986            let shader_items: Vec<_> = chunk
8987                .iter()
8988                .filter_map(|batch| match batch {
8989                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
8990                    _ => None,
8991                })
8992                .flat_map(|(start, end)| {
8993                    ordered_items[start..end].iter().filter_map(|(_, item)| {
8994                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
8995                            return None;
8996                        };
8997                        shader_composites
8998                            .get(*composite_index)
8999                            .map(|(_, composite)| *composite)
9000                    })
9001                })
9002                .collect();
9003            let prepared_shaders = self
9004                .effect_renderer
9005                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items)
9006                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
9007            if !shader_items.is_empty() {
9008                self.effect_renderer.record_composite_pass();
9009                self.effect_renderer
9010                    .debug_effects
9011                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
9012            }
9013            // Span hit: prepare the cached-texture blit that stands in for
9014            // the skipped shapes. Reuses the effect renderer's composite
9015            // machinery — the same prepared-draw path the Composite arms
9016            // ride — with Nearest sampling (an exact `textureLoad`), alpha
9017            // 1.0, no mask, no viewports: a 1:1 full-target replace-write
9018            // of alpha-255 texels (see `StaticSpanCache`).
9019            let span_blit_items =
9020                span_cache
9021                    .texture
9022                    .as_ref()
9023                    .filter(|_| span_skip > 0)
9024                    .map(|texture| CompositeBatchItem {
9025                        source: texture,
9026                        alpha: 1.0,
9027                        scissor: None,
9028                        rounded_mask: None,
9029                        blend_mode: BlendMode::SrcOver,
9030                        dest_viewport: None,
9031                        source_viewport: None,
9032                        sample_mode: CompositeSampleMode::Nearest,
9033                    });
9034            let span_blit = match &span_blit_items {
9035                Some(item) => self.effect_renderer.prepare_composite_batch_draws(
9036                    frame_encoder,
9037                    &device,
9038                    load_op,
9039                    std::slice::from_ref(item),
9040                ),
9041                None => Vec::new(),
9042            };
9043            let after_composite_prepare = Instant::now();
9044
9045            if fused_batches.is_empty() && span_blit.is_empty() {
9046                return Ok(SegmentRenderOutcome {
9047                    rendered_any: false,
9048                    pass_count: 0,
9049                });
9050            }
9051
9052            // The direct shape copies must be recorded before the staged
9053            // flush: its capacity check may replace `upload_buffer`, and the
9054            // shape payload was written into the buffer that existed at
9055            // prepare time. Recording first binds the copies to that buffer.
9056            self.flush_staged_uploads_at(
9057                frame_encoder.encoder(),
9058                &direct_shape_uploads,
9059                shape_upload_base,
9060            );
9061            let upload_offset =
9062                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9063            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
9064            let after_upload = Instant::now();
9065
9066            let use_retained_bundles = retained_bundles_enabled();
9067            let mut retained_encode_ms = 0.0_f64;
9068            {
9069                let mut render_pass =
9070                    frame_encoder
9071                        .encoder()
9072                        .begin_render_pass(&wgpu::RenderPassDescriptor {
9073                            label: Some("Fused Segment Draw Pass"),
9074                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9075                                view: target_view,
9076                                resolve_target: None,
9077                                depth_slice: None,
9078                                ops: wgpu::Operations {
9079                                    load: load_op,
9080                                    store: wgpu::StoreOp::Store,
9081                                },
9082                            })],
9083                            depth_stencil_attachment: None,
9084                            timestamp_writes: None,
9085                            occlusion_query_set: None,
9086                            multiview_mask: None,
9087                        });
9088
9089                // The cached span composite replaces the frame's leading
9090                // draws, so it goes down before every fused batch — same
9091                // z position the skipped shapes held.
9092                for draw in &span_blit {
9093                    self.effect_renderer.draw_prepared_composite(
9094                        &mut render_pass,
9095                        (width, height),
9096                        draw,
9097                    );
9098                }
9099                for batch in &fused_batches {
9100                    match batch {
9101                        FusedSegmentBatch::Shape { batch, blend_mode } => {
9102                            self.draw_prepared_shapes(
9103                                &mut render_pass,
9104                                *blend_mode,
9105                                *batch,
9106                                width,
9107                                height,
9108                                &chunk_rims,
9109                            );
9110                        }
9111                        FusedSegmentBatch::Image {
9112                            cmd_range,
9113                            blend_mode,
9114                        } => {
9115                            self.draw_native_prepared_image_cmd_range(
9116                                &mut render_pass,
9117                                &image_cmds,
9118                                cmd_range.clone(),
9119                                *blend_mode,
9120                            )?;
9121                        }
9122                        FusedSegmentBatch::Text {
9123                            image_cmd_range,
9124                            glyph_cmd_range,
9125                        } => {
9126                            if !image_cmd_range.is_empty() {
9127                                self.draw_native_prepared_image_cmd_range(
9128                                    &mut render_pass,
9129                                    &image_cmds,
9130                                    image_cmd_range.clone(),
9131                                    BlendMode::SrcOver,
9132                                )?;
9133                                self.frame_stats.bump_text();
9134                            }
9135                            if !glyph_cmd_range.is_empty() {
9136                                self.draw_native_prepared_glyph_cmd_range(
9137                                    &mut render_pass,
9138                                    &glyph_cmds,
9139                                    glyph_cmd_range.clone(),
9140                                )?;
9141                            }
9142                        }
9143                        FusedSegmentBatch::Composite { draw_range } => {
9144                            for draw in
9145                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
9146                                    "composite draw range is outside the prepared command buffer"
9147                                        .to_string()
9148                                })?
9149                            {
9150                                self.effect_renderer.draw_prepared_composite(
9151                                    &mut render_pass,
9152                                    (width, height),
9153                                    draw,
9154                                );
9155                            }
9156                        }
9157                        FusedSegmentBatch::ShaderComposite { draw_range } => {
9158                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
9159                                "shader composite draw range is outside the prepared command buffer"
9160                                    .to_string()
9161                            })? {
9162                                self.effect_renderer.draw_prepared_shader_src_over(
9163                                    &device,
9164                                    &mut render_pass,
9165                                    (width, height),
9166                                    draw,
9167                                );
9168                            }
9169                        }
9170                        FusedSegmentBatch::Retained { item_range } => {
9171                            // Each Retained arm is one MAXIMAL consecutive
9172                            // retained stretch — the planner groups adjacent
9173                            // retained items into a single batch — so caching
9174                            // per arm never flattens across the dynamic
9175                            // batches interleaved at their z positions.
9176                            let retained_start = Instant::now();
9177                            if use_retained_bundles {
9178                                self.draw_retained_stretch_bundled(
9179                                    &mut render_pass,
9180                                    ordered_items,
9181                                    retained_draws,
9182                                    item_range.clone(),
9183                                    width,
9184                                    height,
9185                                );
9186                            } else {
9187                                for (_, item) in &ordered_items[item_range.clone()] {
9188                                    if let SegmentDrawItem::Retained(index) = item {
9189                                        if let Some(retained) = retained_draws.get(*index) {
9190                                            self.draw_retained_batch(
9191                                                &mut render_pass,
9192                                                retained,
9193                                                *index,
9194                                                width,
9195                                                height,
9196                                            );
9197                                        }
9198                                    }
9199                                }
9200                            }
9201                            retained_encode_ms += instant_ms(retained_start, Instant::now());
9202                        }
9203                    }
9204                }
9205            }
9206            // Span capture (miss frames whose leading run proved stable):
9207            // re-render JUST the span shapes into the pooled offscreen,
9208            // through the IDENTICAL pipelines at identical device
9209            // coordinates — the shapes are already in this partition's
9210            // upload, so the capture is one extra pass drawing instances
9211            // 0..len of the same buffers, cleared with the frame's own
9212            // clear color. Rare by construction: palette drains, shakes,
9213            // and resizes are the only events that invalidate the key.
9214            let mut capture_passes = 0_u32;
9215            if let StaticSpanDecision::Capture { len, clear } = span_decision {
9216                let texture = match span_cache.texture.take() {
9217                    Some(existing) if existing.width == width && existing.height == height => {
9218                        existing
9219                    }
9220                    other => {
9221                        if let Some(stale) = other {
9222                            self.defer_offscreen_release(stale);
9223                        }
9224                        self.acquire_offscreen(width, height)
9225                    }
9226                };
9227                {
9228                    let mut capture_pass =
9229                        frame_encoder
9230                            .encoder()
9231                            .begin_render_pass(&wgpu::RenderPassDescriptor {
9232                                label: Some("Static Span Capture Pass"),
9233                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9234                                    view: &texture.view,
9235                                    resolve_target: None,
9236                                    depth_slice: None,
9237                                    ops: wgpu::Operations {
9238                                        load: wgpu::LoadOp::Clear(clear),
9239                                        store: wgpu::StoreOp::Store,
9240                                    },
9241                                })],
9242                                depth_stencil_attachment: None,
9243                                timestamp_writes: None,
9244                                occlusion_query_set: None,
9245                                multiview_mask: None,
9246                            });
9247                    // `has_gradient` is the LIVE first batch's whole-batch
9248                    // flag: it selects the same fs_solid/gradient pipeline
9249                    // variant the live path draws the span through.
9250                    let has_gradient = first_batch_info
9251                        .map(|(_, _, has_gradient)| has_gradient)
9252                        .unwrap_or(false);
9253                    self.draw_prepared_shapes(
9254                        &mut capture_pass,
9255                        BlendMode::SrcOver,
9256                        PreparedShapeBatch {
9257                            vertex_start: 0,
9258                            vertex_count: len as u32 * 6,
9259                            has_gradient,
9260                        },
9261                        width,
9262                        height,
9263                        &[],
9264                    );
9265                    if fill_area_diag_enabled() {
9266                        // The capture genuinely re-submits the span's fill
9267                        // this frame — submitted, lit, opacity and (the
9268                        // capture target is frame-sized) corner alike.
9269                        self.fill_area_diag
9270                            .add_shape_quads(&self.scratch_shape_data[..len], viewport);
9271                    }
9272                    span_cache.store_key(
9273                        &self.scratch_shape_data[..len],
9274                        &self.scratch_gradients,
9275                        width,
9276                        height,
9277                        clear,
9278                        has_gradient,
9279                    );
9280                }
9281                span_cache.texture = Some(texture);
9282                capture_passes = 1;
9283            }
9284            let after_pass = Instant::now();
9285            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
9286                log::warn!(
9287                    "[wgpu-render-stage:fused-segment] total_ms={total_ms:.2} shape_refs_ms={:.2} shape_prepare_ms={:.2} batch_prepare_ms={:.2} composite_prepare_ms={:.2} upload_ms={:.2} pass_ms={:.2} retained_encode_ms={retained_encode_ms:.3} batches={} shapes={} image_cmds={} glyph_cmds={} staged_bytes={}",
9288                    instant_ms(partition_start, after_shape_refs),
9289                    instant_ms(after_shape_refs, after_shape_prepare),
9290                    instant_ms(after_shape_prepare, after_batch_prepare),
9291                    instant_ms(after_batch_prepare, after_composite_prepare),
9292                    instant_ms(after_composite_prepare, after_upload),
9293                    instant_ms(after_upload, after_pass),
9294                    fused_batches.len(),
9295                    budget.shape_count,
9296                    image_cmds.len(),
9297                    glyph_cmds.len(),
9298                    staged_uploads.bytes.len(),
9299                );
9300            }
9301
9302            Ok(SegmentRenderOutcome {
9303                rendered_any: true,
9304                pass_count: 1 + capture_passes,
9305            })
9306        })();
9307
9308        self.scratch_image_vertices = image_vertices;
9309        self.scratch_image_indices = image_indices;
9310        self.scratch_image_cmds = image_cmds;
9311        self.scratch_glyph_cmds = glyph_cmds;
9312        self.restore_staged_uploads(staged_uploads);
9313        self.static_span = span_cache;
9314        result
9315    }
9316
9317    #[allow(clippy::too_many_arguments)]
9318    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
9319        &mut self,
9320        frame_encoder: &mut C,
9321        target_view: &wgpu::TextureView,
9322        ordered_items: &[(usize, SegmentDrawItem)],
9323        composites: &[(usize, CompositeBatchItem<'_>)],
9324        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
9325        shapes: &[DrawShape],
9326        brushes: &[Brush],
9327        images: &[ImageDraw],
9328        texts: &[TextDraw],
9329        retained_draws: &[RetainedDraw],
9330        chunk: SegmentDrawChunkPlan,
9331        width: u32,
9332        height: u32,
9333        root_scale: f32,
9334        load_op: wgpu::LoadOp<wgpu::Color>,
9335    ) -> Result<SegmentRenderOutcome, String> {
9336        #[cfg(target_arch = "wasm32")]
9337        let _ = retained_draws;
9338        #[cfg(not(target_arch = "wasm32"))]
9339        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
9340            frame_encoder,
9341            target_view,
9342            ordered_items,
9343            composites,
9344            shader_composites,
9345            shapes,
9346            brushes,
9347            images,
9348            texts,
9349            retained_draws,
9350            &chunk,
9351            width,
9352            height,
9353            root_scale,
9354            load_op,
9355        )? {
9356            return Ok(outcome);
9357        }
9358
9359        let mut staged_uploads = self.take_staged_uploads();
9360        let result = (|| {
9361            let mut rendered_any = false;
9362            let mut pass_count = 0_u32;
9363            let mut next_load_op = load_op;
9364            for batch in chunk.iter() {
9365                staged_uploads.clear();
9366                match batch {
9367                    SegmentBatchPlan::Shape {
9368                        start,
9369                        end,
9370                        blend_mode,
9371                    } => {
9372                        let slice = &ordered_items[start..end];
9373                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
9374                            return Err(format!(
9375                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
9376                                slice.len(),
9377                                self.shape_batch_limits.max_shapes_per_batch
9378                            ));
9379                        }
9380                        let viewport = ViewportUniformParams {
9381                            width,
9382                            height,
9383                            offset: [0.0, 0.0],
9384                        };
9385                        for (_, item) in slice {
9386                            if !matches!(item, SegmentDrawItem::Shape(_)) {
9387                                return Err(format!(
9388                                    "shape batch contains non-shape draw item: {item:?}"
9389                                ));
9390                            }
9391                        }
9392                        let Some(prepared) = self.prepare_shapes_batch(
9393                            slice.iter().filter_map(|(_, item)| match item {
9394                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
9395                                _ => None,
9396                            }),
9397                            brushes,
9398                            root_scale,
9399                            viewport,
9400                            &mut staged_uploads,
9401                        ) else {
9402                            continue;
9403                        };
9404                        let upload_offset = frame_encoder
9405                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9406                        self.flush_staged_uploads_at(
9407                            frame_encoder.encoder(),
9408                            &staged_uploads,
9409                            upload_offset,
9410                        );
9411                        {
9412                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
9413                                &wgpu::RenderPassDescriptor {
9414                                    label: Some("Segment Shape Pass"),
9415                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9416                                        view: target_view,
9417                                        resolve_target: None,
9418                                        depth_slice: None,
9419                                        ops: wgpu::Operations {
9420                                            load: next_load_op,
9421                                            store: wgpu::StoreOp::Store,
9422                                        },
9423                                    })],
9424                                    depth_stencil_attachment: None,
9425                                    timestamp_writes: None,
9426                                    occlusion_query_set: None,
9427                                    multiview_mask: None,
9428                                },
9429                            );
9430                            self.draw_prepared_shapes(
9431                                &mut render_pass,
9432                                blend_mode,
9433                                prepared,
9434                                width,
9435                                height,
9436                                &[],
9437                            );
9438                        }
9439                        pass_count = pass_count.saturating_add(1);
9440                        rendered_any = true;
9441                        next_load_op = wgpu::LoadOp::Load;
9442                    }
9443                    SegmentBatchPlan::Image {
9444                        start,
9445                        end,
9446                        blend_mode,
9447                    } => {
9448                        let viewport = ViewportUniformParams {
9449                            width,
9450                            height,
9451                            offset: [0.0, 0.0],
9452                        };
9453                        for (_, item) in &ordered_items[start..end] {
9454                            if !matches!(item, SegmentDrawItem::Image(_)) {
9455                                return Err(format!(
9456                                    "image batch contains non-image draw item: {item:?}"
9457                                ));
9458                            }
9459                        }
9460                        let prepared_images = self.prepare_image_draw_cmds(
9461                            ordered_items[start..end]
9462                                .iter()
9463                                .filter_map(|(_, item)| match item {
9464                                    SegmentDrawItem::Image(image_index) => {
9465                                        Some(&images[*image_index])
9466                                    }
9467                                    _ => None,
9468                                }),
9469                            viewport,
9470                            root_scale,
9471                            &mut staged_uploads,
9472                        )?;
9473                        if prepared_images.is_empty() {
9474                            self.scratch_image_cmds = prepared_images.into_cmds();
9475                            continue;
9476                        }
9477                        let upload_offset = frame_encoder
9478                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9479                        self.flush_staged_uploads_at(
9480                            frame_encoder.encoder(),
9481                            &staged_uploads,
9482                            upload_offset,
9483                        );
9484                        let draw_result = {
9485                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
9486                                &wgpu::RenderPassDescriptor {
9487                                    label: Some("Segment Image Pass"),
9488                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9489                                        view: target_view,
9490                                        resolve_target: None,
9491                                        depth_slice: None,
9492                                        ops: wgpu::Operations {
9493                                            load: next_load_op,
9494                                            store: wgpu::StoreOp::Store,
9495                                        },
9496                                    })],
9497                                    depth_stencil_attachment: None,
9498                                    timestamp_writes: None,
9499                                    occlusion_query_set: None,
9500                                    multiview_mask: None,
9501                                },
9502                            );
9503                            self.draw_prepared_images(
9504                                &mut render_pass,
9505                                &prepared_images,
9506                                blend_mode,
9507                            )
9508                        };
9509                        pass_count = pass_count.saturating_add(1);
9510                        self.scratch_image_cmds = prepared_images.into_cmds();
9511                        draw_result?;
9512                        rendered_any = true;
9513                        next_load_op = wgpu::LoadOp::Load;
9514                    }
9515                    SegmentBatchPlan::Text { start, end } => {
9516                        let viewport = ViewportUniformParams {
9517                            width,
9518                            height,
9519                            offset: [0.0, 0.0],
9520                        };
9521                        let text_draws =
9522                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
9523                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
9524                            text_draws,
9525                            viewport,
9526                            root_scale,
9527                            &mut staged_uploads,
9528                        )? {
9529                            if prepared_glyphs.is_empty() {
9530                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
9531                                continue;
9532                            }
9533                            let upload_offset = frame_encoder
9534                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9535                            self.flush_staged_uploads_at(
9536                                frame_encoder.encoder(),
9537                                &staged_uploads,
9538                                upload_offset,
9539                            );
9540                            {
9541                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
9542                                    &wgpu::RenderPassDescriptor {
9543                                        label: Some("Segment Text Glyph Atlas Pass"),
9544                                        color_attachments: &[Some(
9545                                            wgpu::RenderPassColorAttachment {
9546                                                view: target_view,
9547                                                resolve_target: None,
9548                                                depth_slice: None,
9549                                                ops: wgpu::Operations {
9550                                                    load: next_load_op,
9551                                                    store: wgpu::StoreOp::Store,
9552                                                },
9553                                            },
9554                                        )],
9555                                        depth_stencil_attachment: None,
9556                                        timestamp_writes: None,
9557                                        occlusion_query_set: None,
9558                                        multiview_mask: None,
9559                                    },
9560                                );
9561                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
9562                            }
9563                            pass_count = pass_count.saturating_add(1);
9564                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
9565                            rendered_any = true;
9566                            next_load_op = wgpu::LoadOp::Load;
9567                        } else {
9568                            let text_draws =
9569                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
9570                            let prepared_images = self.prepare_text_image_draw_cmds(
9571                                text_draws,
9572                                viewport,
9573                                root_scale,
9574                                &mut staged_uploads,
9575                            )?;
9576                            if prepared_images.is_empty() {
9577                                self.scratch_image_cmds = prepared_images.into_cmds();
9578                                continue;
9579                            }
9580                            let upload_offset = frame_encoder
9581                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9582                            self.flush_staged_uploads_at(
9583                                frame_encoder.encoder(),
9584                                &staged_uploads,
9585                                upload_offset,
9586                            );
9587                            {
9588                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
9589                                    &wgpu::RenderPassDescriptor {
9590                                        label: Some("Segment Text Pass"),
9591                                        color_attachments: &[Some(
9592                                            wgpu::RenderPassColorAttachment {
9593                                                view: target_view,
9594                                                resolve_target: None,
9595                                                depth_slice: None,
9596                                                ops: wgpu::Operations {
9597                                                    load: next_load_op,
9598                                                    store: wgpu::StoreOp::Store,
9599                                                },
9600                                            },
9601                                        )],
9602                                        depth_stencil_attachment: None,
9603                                        timestamp_writes: None,
9604                                        occlusion_query_set: None,
9605                                        multiview_mask: None,
9606                                    },
9607                                );
9608                                self.draw_prepared_images(
9609                                    &mut render_pass,
9610                                    &prepared_images,
9611                                    BlendMode::SrcOver,
9612                                )?;
9613                            }
9614                            self.frame_stats.bump_text();
9615                            pass_count = pass_count.saturating_add(1);
9616                            self.scratch_image_cmds = prepared_images.into_cmds();
9617                            rendered_any = true;
9618                            next_load_op = wgpu::LoadOp::Load;
9619                        }
9620                    }
9621                    SegmentBatchPlan::Composite { start, end } => {
9622                        let batch_items: Vec<_> = ordered_items[start..end]
9623                            .iter()
9624                            .map(|(_, item)| match item {
9625                                SegmentDrawItem::Composite(composite_index) => composites
9626                                    .get(*composite_index)
9627                                    .map(|(_, composite)| *composite)
9628                                    .ok_or_else(|| {
9629                                        "composite item index is outside the composite buffer"
9630                                            .to_string()
9631                                    }),
9632                                other => Err(format!(
9633                                    "composite batch contains non-composite draw item: {other:?}"
9634                                )),
9635                            })
9636                            .collect::<Result<_, _>>()?;
9637                        let device = self.device.clone();
9638                        self.effect_renderer.encode_composite_batch_to_view_pass(
9639                            frame_encoder,
9640                            &device,
9641                            target_view,
9642                            (width, height),
9643                            next_load_op,
9644                            &batch_items,
9645                        );
9646                        self.effect_renderer.record_composite_pass();
9647                        pass_count = pass_count.saturating_add(1);
9648                        rendered_any = true;
9649                        next_load_op = wgpu::LoadOp::Load;
9650                    }
9651                    SegmentBatchPlan::ShaderComposite { start, end } => {
9652                        let batch_items: Vec<_> = ordered_items[start..end]
9653                            .iter()
9654                            .map(|(_, item)| match item {
9655                                SegmentDrawItem::ShaderComposite(composite_index) => {
9656                                    shader_composites
9657                                        .get(*composite_index)
9658                                        .map(|(_, composite)| *composite)
9659                                        .ok_or_else(|| {
9660                                            "shader composite item index is outside the shader composite buffer"
9661                                                .to_string()
9662                                        })
9663                                }
9664                                other => Err(format!(
9665                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
9666                                )),
9667                            })
9668                            .collect::<Result<Vec<_>, _>>()?;
9669                        let device = self.device.clone();
9670                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
9671                            frame_encoder,
9672                            &device,
9673                            target_view,
9674                            (width, height),
9675                            next_load_op,
9676                            &batch_items,
9677                        );
9678                        if !encoded {
9679                            return Err("shader composite batch failed to encode".to_string());
9680                        }
9681                        self.effect_renderer.record_composite_pass();
9682                        self.effect_renderer.debug_effects.set(
9683                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
9684                        );
9685                        pass_count = pass_count.saturating_add(1);
9686                        rendered_any = true;
9687                        next_load_op = wgpu::LoadOp::Load;
9688                    }
9689                    SegmentBatchPlan::Retained { start, end } => {
9690                        // Reached only when native fusion declined the chunk;
9691                        // retained batches exist on storage-mode native
9692                        // devices, where fusion always accepts, but the arm
9693                        // stays a real draw so that assumption is not load-
9694                        // bearing for correctness. Deliberately direct encode
9695                        // — retained bundle caching lives in the fused path
9696                        // only; this fallback stays the simple reference.
9697                        #[cfg(target_arch = "wasm32")]
9698                        {
9699                            let _ = (start, end);
9700                            return Err("retained shape batches are native-only".to_string());
9701                        }
9702                        #[cfg(not(target_arch = "wasm32"))]
9703                        {
9704                            self.stage_replay_patches(&mut staged_uploads);
9705                            for (_, item) in &ordered_items[start..end] {
9706                                let SegmentDrawItem::Retained(index) = item else {
9707                                    return Err(format!(
9708                                        "retained batch contains non-retained draw item: {item:?}"
9709                                    ));
9710                                };
9711                                let retained = retained_draws.get(*index).ok_or_else(|| {
9712                                    format!("retained draw index {index} out of bounds")
9713                                })?;
9714                                if (*index as u32) < MAX_REPLAY_SLOTS
9715                                    && self.replay_slots.slots.contains_key(&retained.slot)
9716                                {
9717                                    let transform = retained.transform.with_retained_paint();
9718                                    staged_uploads.stage_at(
9719                                        UploadTarget::ReplayTransform,
9720                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
9721                                        bytemuck::bytes_of(&transform),
9722                                    );
9723                                }
9724                            }
9725                            let upload_offset = frame_encoder
9726                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9727                            self.flush_staged_uploads_at(
9728                                frame_encoder.encoder(),
9729                                &staged_uploads,
9730                                upload_offset,
9731                            );
9732                            {
9733                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
9734                                    &wgpu::RenderPassDescriptor {
9735                                        label: Some("Segment Retained Pass"),
9736                                        color_attachments: &[Some(
9737                                            wgpu::RenderPassColorAttachment {
9738                                                view: target_view,
9739                                                resolve_target: None,
9740                                                depth_slice: None,
9741                                                ops: wgpu::Operations {
9742                                                    load: next_load_op,
9743                                                    store: wgpu::StoreOp::Store,
9744                                                },
9745                                            },
9746                                        )],
9747                                        depth_stencil_attachment: None,
9748                                        timestamp_writes: None,
9749                                        occlusion_query_set: None,
9750                                        multiview_mask: None,
9751                                    },
9752                                );
9753                                for (_, item) in &ordered_items[start..end] {
9754                                    if let SegmentDrawItem::Retained(index) = item {
9755                                        if let Some(retained) = retained_draws.get(*index) {
9756                                            self.draw_retained_batch(
9757                                                &mut render_pass,
9758                                                retained,
9759                                                *index,
9760                                                width,
9761                                                height,
9762                                            );
9763                                        }
9764                                    }
9765                                }
9766                            }
9767                            pass_count = pass_count.saturating_add(1);
9768                            rendered_any = true;
9769                            next_load_op = wgpu::LoadOp::Load;
9770                        }
9771                    }
9772                }
9773            }
9774            Ok(SegmentRenderOutcome {
9775                rendered_any,
9776                pass_count,
9777            })
9778        })();
9779        self.restore_staged_uploads(staged_uploads);
9780        result
9781    }
9782
9783    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
9784        Uniforms {
9785            viewport: [params.width as f32, params.height as f32],
9786            viewport_offset: params.offset,
9787        }
9788    }
9789
9790    #[cfg(not(target_arch = "wasm32"))]
9791    fn stage_viewport_uniforms(
9792        &self,
9793        staged_uploads: &mut StagedBufferUploads,
9794        params: ViewportUniformParams,
9795    ) {
9796        let uniforms = Self::viewport_uniforms(params);
9797        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
9798    }
9799
9800    #[cfg(not(target_arch = "wasm32"))]
9801    fn stage_retained_glyph_viewport_uniforms(
9802        &mut self,
9803        staged_uploads: &mut StagedBufferUploads,
9804        params: ViewportUniformParams,
9805    ) -> usize {
9806        let slot = self.claim_retained_glyph_uniform_slot();
9807        let uniforms = Self::viewport_uniforms(params);
9808        staged_uploads.stage_at(
9809            UploadTarget::RetainedGlyphUniform,
9810            self.retained_glyph_uniform_offset(slot),
9811            bytemuck::bytes_of(&uniforms),
9812        );
9813        slot
9814    }
9815
9816    #[cfg(not(target_arch = "wasm32"))]
9817    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
9818        let slot = self.retained_glyph_uniform_cursor;
9819        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
9820        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
9821        slot
9822    }
9823
9824    #[cfg(not(target_arch = "wasm32"))]
9825    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
9826        self.retained_glyph_uniform_stride * slot as u64
9827    }
9828
9829    #[cfg(not(target_arch = "wasm32"))]
9830    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
9831        let offset = self.retained_glyph_uniform_offset(slot);
9832        u32::try_from(offset).map_err(|_| {
9833            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
9834        })
9835    }
9836
9837    #[cfg(not(target_arch = "wasm32"))]
9838    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
9839        if required_slots <= self.retained_glyph_uniform_capacity {
9840            return;
9841        }
9842        let new_capacity = required_slots
9843            .next_power_of_two()
9844            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
9845        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9846            label: Some("Retained Glyph Uniform Buffer"),
9847            size: self.retained_glyph_uniform_stride * new_capacity as u64,
9848            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
9849            mapped_at_creation: false,
9850        });
9851        self.retained_glyph_uniform_bind_group =
9852            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
9853                label: Some("Retained Glyph Uniform Bind Group"),
9854                layout: &self.retained_glyph_uniform_bind_group_layout,
9855                entries: &[wgpu::BindGroupEntry {
9856                    binding: 0,
9857                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
9858                        buffer: &self.retained_glyph_uniform_buffer,
9859                        offset: 0,
9860                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
9861                    }),
9862                }],
9863            });
9864        self.retained_glyph_uniform_capacity = new_capacity;
9865    }
9866
9867    #[cfg(target_arch = "wasm32")]
9868    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
9869        let slot = self.claim_wasm_uniform_batch();
9870        let uniforms = Self::viewport_uniforms(params);
9871        let bytes = bytemuck::bytes_of(&uniforms);
9872        let upload_stats = self.frame_graph_executor.upload_buffer(
9873            &self.queue,
9874            &self.wasm_uniform_batches[slot].buffer,
9875            0,
9876            bytes,
9877        );
9878        self.frame_stats.record_command_stats(upload_stats);
9879        slot
9880    }
9881
9882    #[cfg(target_arch = "wasm32")]
9883    fn claim_wasm_uniform_batch(&mut self) -> usize {
9884        let slot = self.wasm_uniform_batch_cursor;
9885        self.wasm_uniform_batch_cursor += 1;
9886        while self.wasm_uniform_batches.len() <= slot {
9887            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
9888                &self.device,
9889                &self.uniform_bind_group_layout,
9890            ));
9891        }
9892        slot
9893    }
9894
9895    #[cfg(target_arch = "wasm32")]
9896    fn claim_wasm_shape_batch(&mut self) -> usize {
9897        let slot = self.wasm_shape_batch_cursor;
9898        self.wasm_shape_batch_cursor += 1;
9899        while self.wasm_shape_batches.len() <= slot {
9900            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
9901                &self.device,
9902                &self.shape_bind_group_layout,
9903                &self.identity_similarity_buffer,
9904                self.dummy_paint_buffer.as_ref(),
9905                self.shape_batch_limits,
9906            ));
9907        }
9908        slot
9909    }
9910
9911    #[cfg(target_arch = "wasm32")]
9912    fn claim_wasm_image_batch(&mut self) -> usize {
9913        let slot = self.wasm_image_batch_cursor;
9914        self.wasm_image_batch_cursor += 1;
9915        while self.wasm_image_batches.len() <= slot {
9916            self.wasm_image_batches
9917                .push(ImageBatchBuffers::new(&self.device));
9918        }
9919        slot
9920    }
9921
9922    #[cfg(target_arch = "wasm32")]
9923    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
9924        let upload_stats = self
9925            .frame_graph_executor
9926            .upload_buffer(&self.queue, buffer, 0, bytes);
9927        self.frame_stats.record_command_stats(upload_stats);
9928    }
9929
9930    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
9931        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
9932        debug_assert!(
9933            staged_uploads.is_empty(),
9934            "renderer-owned staged uploads should be restored as empty scratch storage"
9935        );
9936        staged_uploads.clear();
9937        staged_uploads
9938    }
9939
9940    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
9941        staged_uploads.clear();
9942        self.staged_uploads = staged_uploads;
9943    }
9944
9945    #[cfg(not(target_arch = "wasm32"))]
9946    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
9947        if required_bytes <= self.upload_buffer.size() {
9948            return;
9949        }
9950
9951        let new_size = required_bytes
9952            .next_power_of_two()
9953            .max(INITIAL_UPLOAD_BUFFER_BYTES);
9954        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9955            label: Some("Frame Upload Buffer"),
9956            size: new_size,
9957            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
9958            mapped_at_creation: false,
9959        });
9960    }
9961
9962    fn flush_staged_uploads_at(
9963        &mut self,
9964        encoder: &mut wgpu::CommandEncoder,
9965        staged_uploads: &StagedBufferUploads,
9966        upload_buffer_offset: u64,
9967    ) {
9968        if staged_uploads.is_empty() {
9969            return;
9970        }
9971        debug_assert_eq!(
9972            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
9973            0,
9974            "upload-buffer base offset must satisfy copy alignment"
9975        );
9976
9977        #[cfg(target_arch = "wasm32")]
9978        {
9979            let _ = upload_buffer_offset;
9980            let _ = encoder;
9981            debug_assert!(
9982                staged_uploads.is_empty(),
9983                "wasm draw uploads use retained per-batch resource slots"
9984            );
9985            return;
9986        }
9987
9988        #[cfg(not(target_arch = "wasm32"))]
9989        {
9990            self.ensure_upload_buffer_capacity(
9991                upload_buffer_offset + staged_uploads.bytes.len() as u64,
9992            );
9993            let upload_stats = self.frame_graph_executor.upload_buffer(
9994                &self.queue,
9995                &self.upload_buffer,
9996                upload_buffer_offset,
9997                &staged_uploads.bytes,
9998            );
9999            self.frame_stats.record_command_stats(upload_stats);
10000
10001            for copy in &staged_uploads.copies {
10002                let target_buffer = match copy.target {
10003                    UploadTarget::Uniform => &self.uniform_buffer,
10004                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
10005                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
10006                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
10007                    UploadTarget::ImageIndex => &self.image_index_buffer,
10008                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
10009                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
10010                    UploadTarget::ReplayPaintData(slot) => {
10011                        // A slot released between staging and flush has
10012                        // nothing left to patch.
10013                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
10014                            continue;
10015                        };
10016                        &entry.paint_buffer
10017                    }
10018                };
10019                encoder.copy_buffer_to_buffer(
10020                    &self.upload_buffer,
10021                    upload_buffer_offset + copy.source_offset,
10022                    target_buffer,
10023                    copy.target_offset,
10024                    copy.size,
10025                );
10026            }
10027        }
10028    }
10029
10030    #[allow(clippy::too_many_arguments)]
10031    fn encode_shadow_draw<C: FrameCommandRecorder>(
10032        &mut self,
10033        frame_encoder: &mut C,
10034        target_view: &wgpu::TextureView,
10035        shadow: &ShadowDraw,
10036        width: u32,
10037        height: u32,
10038        root_scale: f32,
10039    ) {
10040        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
10041            return;
10042        }
10043
10044        let shape_bounds_opt = shadow
10045            .shapes
10046            .iter()
10047            .map(|(shape, _)| shape.rect)
10048            .reduce(|a, b| Rect {
10049                x: a.x.min(b.x),
10050                y: a.y.min(b.y),
10051                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
10052                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
10053            });
10054
10055        let text_bounds_opt = shadow
10056            .texts
10057            .iter()
10058            .map(|text| text.rect)
10059            .reduce(|a, b| Rect {
10060                x: a.x.min(b.x),
10061                y: a.y.min(b.y),
10062                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
10063                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
10064            });
10065
10066        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
10067            (Some(s), Some(t)) => Some(Rect {
10068                x: s.x.min(t.x),
10069                y: s.y.min(t.y),
10070                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
10071                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
10072            }),
10073            (Some(s), None) => Some(s),
10074            (None, Some(t)) => Some(t),
10075            (None, None) => None,
10076        };
10077
10078        let Some(shape_bounds) = combined_bounds else {
10079            return;
10080        };
10081
10082        let blur_margin = blur_extent_margin(shadow.blur_radius);
10083        let source_blur_bounds = Rect {
10084            x: shape_bounds.x - blur_margin,
10085            y: shape_bounds.y - blur_margin,
10086            width: shape_bounds.width + blur_margin * 2.0,
10087            height: shape_bounds.height + blur_margin * 2.0,
10088        };
10089        let mut visible_blur_bounds = source_blur_bounds;
10090        if let Some(clip) = shadow.clip {
10091            let clip_expanded = Rect {
10092                x: clip.x - blur_margin,
10093                y: clip.y - blur_margin,
10094                width: clip.width + blur_margin * 2.0,
10095                height: clip.height + blur_margin * 2.0,
10096            };
10097            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
10098                return;
10099            };
10100            visible_blur_bounds = intersection;
10101        }
10102        let processing_scissor =
10103            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
10104        if processing_scissor.is_none() {
10105            return;
10106        }
10107
10108        // Zero blur: render shapes directly to target (fast path).
10109        if shadow.blur_radius <= 0.0 {
10110            for (shape, blend_mode) in &shadow.shapes {
10111                self.encode_shapes_pass(
10112                    frame_encoder,
10113                    target_view,
10114                    std::iter::once(shape),
10115                    &shadow.brushes,
10116                    *blend_mode,
10117                    width,
10118                    height,
10119                    root_scale,
10120                    wgpu::LoadOp::Load,
10121                    [0.0, 0.0],
10122                );
10123                frame_encoder.record_pass();
10124            }
10125            if !shadow.texts.is_empty() {
10126                let mut staged_uploads = self.take_staged_uploads();
10127                let viewport = ViewportUniformParams {
10128                    width,
10129                    height,
10130                    offset: [0.0, 0.0],
10131                };
10132                match self.prepare_text_image_draw_cmds(
10133                    shadow.texts.iter(),
10134                    viewport,
10135                    root_scale,
10136                    &mut staged_uploads,
10137                ) {
10138                    Ok(prepared_images) if !prepared_images.is_empty() => {
10139                        let upload_offset = frame_encoder
10140                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10141                        self.flush_staged_uploads_at(
10142                            frame_encoder.encoder(),
10143                            &staged_uploads,
10144                            upload_offset,
10145                        );
10146                        let draw_result = {
10147                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
10148                                &wgpu::RenderPassDescriptor {
10149                                    label: Some("Zero Blur Shadow Text Image Pass"),
10150                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10151                                        view: target_view,
10152                                        resolve_target: None,
10153                                        depth_slice: None,
10154                                        ops: wgpu::Operations {
10155                                            load: wgpu::LoadOp::Load,
10156                                            store: wgpu::StoreOp::Store,
10157                                        },
10158                                    })],
10159                                    depth_stencil_attachment: None,
10160                                    timestamp_writes: None,
10161                                    occlusion_query_set: None,
10162                                    multiview_mask: None,
10163                                },
10164                            );
10165                            self.draw_prepared_images(
10166                                &mut render_pass,
10167                                &prepared_images,
10168                                BlendMode::SrcOver,
10169                            )
10170                        };
10171                        self.scratch_image_cmds = prepared_images.into_cmds();
10172                        if let Err(e) = draw_result {
10173                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
10174                        } else {
10175                            self.frame_stats.bump_text();
10176                            frame_encoder.record_pass();
10177                        }
10178                    }
10179                    Ok(prepared_images) => {
10180                        self.scratch_image_cmds = prepared_images.into_cmds();
10181                    }
10182                    Err(e) => {
10183                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
10184                    }
10185                }
10186                self.restore_staged_uploads(staged_uploads);
10187            }
10188            return;
10189        }
10190
10191        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
10192        let Some(device_bounds) =
10193            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
10194        else {
10195            return;
10196        };
10197        let bounds_x = device_bounds.x;
10198        let bounds_y = device_bounds.y;
10199        let bounds_w = device_bounds.width;
10200        let bounds_h = device_bounds.height;
10201        let pixel_radius = shadow.blur_radius * root_scale;
10202
10203        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
10204            if let Some(plan) = shape_shadow_surface_plan(
10205                &shadow.shapes,
10206                shadow.clip,
10207                shadow.blur_radius,
10208                width,
10209                height,
10210                root_scale,
10211                self.max_texture_dim(),
10212            ) {
10213                if self.encode_shape_only_blurred_shadow_draw(
10214                    frame_encoder,
10215                    target_view,
10216                    shadow,
10217                    plan.source_device_bounds,
10218                    plan.pixel_radius,
10219                    plan.processing_scissor,
10220                    width,
10221                    height,
10222                    root_scale,
10223                ) {
10224                    return;
10225                }
10226            }
10227        }
10228
10229        if !shadow.texts.is_empty() {
10230            self.frame_stats.record_shadow_text_blur_fallback();
10231        }
10232
10233        let device = self.device.clone();
10234        let source_descriptor =
10235            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
10236        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
10237        let viewport_offset = [bounds_x, bounds_y];
10238        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
10239        let source_outcome = self.encode_shadow_shape_source_passes(
10240            frame_encoder,
10241            &source.view,
10242            &shadow.shapes,
10243            &shadow.brushes,
10244            bounds_w,
10245            bounds_h,
10246            viewport_offset,
10247            root_scale,
10248            &mut next_load_op,
10249        );
10250        frame_encoder.record_passes(source_outcome.pass_count);
10251        let mut rendered_any = source_outcome.rendered_any;
10252
10253        if !shadow.texts.is_empty() {
10254            let mut shifted_texts = shadow.texts.clone();
10255            for text in &mut shifted_texts {
10256                text.rect.x -= viewport_offset[0] / root_scale;
10257                text.rect.y -= viewport_offset[1] / root_scale;
10258                if let Some(clip) = text.clip.as_mut() {
10259                    clip.x -= viewport_offset[0] / root_scale;
10260                    clip.y -= viewport_offset[1] / root_scale;
10261                }
10262            }
10263
10264            let mut staged_uploads = self.take_staged_uploads();
10265            let viewport = ViewportUniformParams {
10266                width: bounds_w,
10267                height: bounds_h,
10268                offset: [0.0, 0.0],
10269            };
10270            match self.prepare_text_image_draw_cmds(
10271                shifted_texts.iter(),
10272                viewport,
10273                root_scale,
10274                &mut staged_uploads,
10275            ) {
10276                Ok(prepared_images) if !prepared_images.is_empty() => {
10277                    let upload_offset = frame_encoder
10278                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10279                    self.flush_staged_uploads_at(
10280                        frame_encoder.encoder(),
10281                        &staged_uploads,
10282                        upload_offset,
10283                    );
10284                    let draw_result = {
10285                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
10286                            &wgpu::RenderPassDescriptor {
10287                                label: Some("Shadow Source Text Image Pass"),
10288                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10289                                    view: &source.view,
10290                                    resolve_target: None,
10291                                    depth_slice: None,
10292                                    ops: wgpu::Operations {
10293                                        load: next_load_op,
10294                                        store: wgpu::StoreOp::Store,
10295                                    },
10296                                })],
10297                                depth_stencil_attachment: None,
10298                                timestamp_writes: None,
10299                                occlusion_query_set: None,
10300                                multiview_mask: None,
10301                            },
10302                        );
10303                        self.draw_prepared_images(
10304                            &mut render_pass,
10305                            &prepared_images,
10306                            BlendMode::SrcOver,
10307                        )
10308                    };
10309                    self.scratch_image_cmds = prepared_images.into_cmds();
10310                    if let Err(e) = draw_result {
10311                        eprintln!("Failed to draw text for shadow: {}", e);
10312                    } else {
10313                        self.frame_stats.bump_text();
10314                        frame_encoder.record_pass();
10315                        rendered_any = true;
10316                    }
10317                }
10318                Ok(prepared_images) => {
10319                    self.scratch_image_cmds = prepared_images.into_cmds();
10320                }
10321                Err(e) => {
10322                    eprintln!("Failed to prepare text image for shadow: {}", e);
10323                }
10324            }
10325            self.restore_staged_uploads(staged_uploads);
10326        }
10327
10328        if !rendered_any {
10329            frame_encoder.release_transient_offscreen(source_descriptor, source);
10330            return;
10331        }
10332
10333        let scratch_descriptor =
10334            self.transient_offscreen_descriptor("Shadow Blur Scratch", bounds_w, bounds_h);
10335        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
10336        {
10337            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
10338                frame_encoder,
10339                &device,
10340                &source,
10341                &scratch,
10342                &source.view,
10343                pixel_radius,
10344                pixel_radius,
10345                TileMode::Decal,
10346                None, // No scissor needed — the texture is already bounds-sized
10347            );
10348        }
10349        frame_encoder.record_passes(2);
10350
10351        let clip_scissor = shadow
10352            .clip
10353            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
10354        let scissor = clip_scissor.or(processing_scissor);
10355        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
10356            // Adjust mask coordinates from viewport-space to texture-local space,
10357            // since the blit shader computes world_pos = uv * tex_size.
10358            mask.rect[0] -= viewport_offset[0];
10359            mask.rect[1] -= viewport_offset[1];
10360            mask
10361        });
10362        let dest_viewport = Some((
10363            viewport_offset[0],
10364            viewport_offset[1],
10365            bounds_w as f32,
10366            bounds_h as f32,
10367        ));
10368        {
10369            self.effect_renderer
10370                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
10371                    frame_encoder,
10372                    &device,
10373                    &source,
10374                    target_view,
10375                    1.0,
10376                    wgpu::LoadOp::Load,
10377                    scissor,
10378                    rounded_mask,
10379                    BlendMode::SrcOver,
10380                    dest_viewport,
10381                    CompositeSampleMode::Linear,
10382                );
10383        }
10384        frame_encoder.record_pass();
10385        self.effect_renderer.record_blur_pass();
10386        self.effect_renderer.record_composite_pass();
10387        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
10388        frame_encoder.release_transient_offscreen(source_descriptor, source);
10389    }
10390
10391    #[allow(clippy::too_many_arguments)]
10392    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
10393        &mut self,
10394        frame_encoder: &mut C,
10395        source_view: &wgpu::TextureView,
10396        shapes: &[(DrawShape, BlendMode)],
10397        brushes: &[Brush],
10398        width: u32,
10399        height: u32,
10400        viewport_offset: [f32; 2],
10401        root_scale: f32,
10402        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
10403    ) -> ShadowSourceRenderOutcome {
10404        if shapes.is_empty() {
10405            return ShadowSourceRenderOutcome {
10406                rendered_any: false,
10407                pass_count: 0,
10408            };
10409        }
10410
10411        let mut staged_uploads = self.take_staged_uploads();
10412        let mut rendered_any = false;
10413        let mut pass_count = 0_u32;
10414        let mut start = 0usize;
10415        while start < shapes.len() {
10416            let blend_mode = supported_blend_mode(shapes[start].1);
10417            let mut end = start + 1;
10418            while end < shapes.len()
10419                && end - start < self.shape_batch_limits.max_shapes_per_batch
10420                && supported_blend_mode(shapes[end].1) == blend_mode
10421            {
10422                end += 1;
10423            }
10424
10425            staged_uploads.clear();
10426            let viewport = ViewportUniformParams {
10427                width,
10428                height,
10429                offset: viewport_offset,
10430            };
10431            let Some(prepared_shape) = self.prepare_shapes_batch(
10432                shapes[start..end]
10433                    .iter()
10434                    .map(|(shape, _blend_mode)| shape)
10435                    .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
10436                brushes,
10437                root_scale,
10438                viewport,
10439                &mut staged_uploads,
10440            ) else {
10441                start = end;
10442                continue;
10443            };
10444
10445            let upload_offset =
10446                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10447            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
10448
10449            {
10450                let mut render_pass =
10451                    frame_encoder
10452                        .encoder()
10453                        .begin_render_pass(&wgpu::RenderPassDescriptor {
10454                            label: Some("Shadow Source Shape Pass"),
10455                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10456                                view: source_view,
10457                                resolve_target: None,
10458                                depth_slice: None,
10459                                ops: wgpu::Operations {
10460                                    load: *next_load_op,
10461                                    store: wgpu::StoreOp::Store,
10462                                },
10463                            })],
10464                            depth_stencil_attachment: None,
10465                            timestamp_writes: None,
10466                            occlusion_query_set: None,
10467                            multiview_mask: None,
10468                        });
10469                self.draw_prepared_shapes(
10470                    &mut render_pass,
10471                    blend_mode,
10472                    prepared_shape,
10473                    width,
10474                    height,
10475                    &[],
10476                );
10477            }
10478
10479            #[cfg(not(target_arch = "wasm32"))]
10480            {
10481                if fill_area_diag_enabled() {
10482                    // Each shadow-source pass round-trips the whole
10483                    // bounds-sized offscreen target (clear on the first
10484                    // pass, load/store after); the shape quads inside were
10485                    // already priced by `prepare_shapes_batch` under this
10486                    // pass's bounds viewport.
10487                    self.fill_area_diag
10488                        .add_offscreen_target_fill(f64::from(width) * f64::from(height));
10489                }
10490            }
10491
10492            pass_count = pass_count.saturating_add(1);
10493            rendered_any = true;
10494            *next_load_op = wgpu::LoadOp::Load;
10495            start = end;
10496        }
10497
10498        self.restore_staged_uploads(staged_uploads);
10499        ShadowSourceRenderOutcome {
10500            rendered_any,
10501            pass_count,
10502        }
10503    }
10504
10505    #[allow(clippy::too_many_arguments)]
10506    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
10507        &mut self,
10508        frame_encoder: &mut C,
10509        target_view: &wgpu::TextureView,
10510        shadow: &ShadowDraw,
10511        device_bounds: DevicePixelBounds,
10512        pixel_radius: f32,
10513        processing_scissor: Option<(u32, u32, u32, u32)>,
10514        width: u32,
10515        height: u32,
10516        root_scale: f32,
10517    ) -> bool {
10518        let bounds_w = device_bounds.width;
10519        let bounds_h = device_bounds.height;
10520        let viewport_offset = [device_bounds.x, device_bounds.y];
10521        let cache_key = shape_shadow_surface_cache_key(
10522            &shadow.shapes,
10523            &shadow.brushes,
10524            device_bounds,
10525            pixel_radius,
10526            root_scale,
10527        );
10528
10529        if let Some(key) = cache_key {
10530            if let Some(cached) = self.cached_shadow_surface(&key) {
10531                self.frame_stats
10532                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
10533                let clip_scissor = shadow
10534                    .clip
10535                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
10536                let scissor = clip_scissor.or(processing_scissor);
10537                let rounded_mask =
10538                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
10539                        mask.rect[0] -= viewport_offset[0];
10540                        mask.rect[1] -= viewport_offset[1];
10541                        mask
10542                    });
10543                let dest_viewport = Some((
10544                    viewport_offset[0],
10545                    viewport_offset[1],
10546                    bounds_w as f32,
10547                    bounds_h as f32,
10548                ));
10549                {
10550                    self.effect_renderer
10551                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
10552                            frame_encoder,
10553                            &self.device,
10554                            &cached,
10555                            target_view,
10556                            1.0,
10557                            wgpu::LoadOp::Load,
10558                            scissor,
10559                            rounded_mask,
10560                            BlendMode::SrcOver,
10561                            dest_viewport,
10562                            CompositeSampleMode::Nearest,
10563                        );
10564                }
10565                frame_encoder.record_pass();
10566                self.effect_renderer.record_composite_pass();
10567                return true;
10568            }
10569            self.frame_stats
10570                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
10571            self.frame_stats.maybe_print_shadow_shape_cache_miss(
10572                bounds_w,
10573                bounds_h,
10574                key.content_hash,
10575                pixel_radius,
10576                viewport_offset,
10577                shadow.shapes.len(),
10578                shadow.clip,
10579            );
10580        }
10581
10582        let device = self.device.clone();
10583        let source_descriptor =
10584            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
10585        let source_is_cacheable = cache_key.is_some();
10586        let source = if source_is_cacheable {
10587            self.acquire_retained_surface(bounds_w, bounds_h)
10588        } else {
10589            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
10590        };
10591        let scratch_descriptor =
10592            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", bounds_w, bounds_h);
10593        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
10594        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
10595        let source_outcome = self.encode_shadow_shape_source_passes(
10596            frame_encoder,
10597            &source.view,
10598            &shadow.shapes,
10599            &shadow.brushes,
10600            bounds_w,
10601            bounds_h,
10602            viewport_offset,
10603            root_scale,
10604            &mut next_load_op,
10605        );
10606        frame_encoder.record_passes(source_outcome.pass_count);
10607
10608        if !source_outcome.rendered_any {
10609            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
10610            if source_is_cacheable {
10611                self.defer_offscreen_release(source);
10612            } else {
10613                frame_encoder.release_transient_offscreen(source_descriptor, source);
10614            }
10615            return true;
10616        }
10617
10618        {
10619            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
10620                frame_encoder,
10621                &device,
10622                &source,
10623                &scratch,
10624                &source.view,
10625                pixel_radius,
10626                pixel_radius,
10627                TileMode::Decal,
10628                None,
10629            );
10630        }
10631        frame_encoder.record_passes(2);
10632
10633        let clip_scissor = shadow
10634            .clip
10635            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
10636        let scissor = clip_scissor.or(processing_scissor);
10637        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
10638            mask.rect[0] -= viewport_offset[0];
10639            mask.rect[1] -= viewport_offset[1];
10640            mask
10641        });
10642        let dest_viewport = Some((
10643            viewport_offset[0],
10644            viewport_offset[1],
10645            bounds_w as f32,
10646            bounds_h as f32,
10647        ));
10648        {
10649            self.effect_renderer
10650                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
10651                    frame_encoder,
10652                    &device,
10653                    &source,
10654                    target_view,
10655                    1.0,
10656                    wgpu::LoadOp::Load,
10657                    scissor,
10658                    rounded_mask,
10659                    BlendMode::SrcOver,
10660                    dest_viewport,
10661                    CompositeSampleMode::Nearest,
10662                );
10663        }
10664        frame_encoder.record_pass();
10665
10666        self.effect_renderer.record_blur_pass();
10667        self.effect_renderer.record_composite_pass();
10668        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
10669        if let Some(key) = cache_key {
10670            self.insert_cached_shadow_surface(key, source);
10671        } else {
10672            frame_encoder.release_transient_offscreen(source_descriptor, source);
10673        }
10674        true
10675    }
10676
10677    fn prepare_shapes_batch<'a, I>(
10678        &mut self,
10679        layer_shapes: I,
10680        brushes: &[Brush],
10681        root_scale: f32,
10682        viewport: ViewportUniformParams,
10683        staged_uploads: &mut StagedBufferUploads,
10684    ) -> Option<PreparedShapeBatch>
10685    where
10686        I: Iterator<Item = &'a DrawShape>,
10687    {
10688        #[cfg(target_arch = "wasm32")]
10689        let _ = staged_uploads;
10690
10691        // Build shape data for this subset. Callers hand in only shapes visible in
10692        // `viewport`: the segment paths culled at collect time, and the layer and
10693        // shadow-source paths filter at the call site. Re-checking here would run
10694        // the same quad math a second time on every shape of every frame.
10695        let shape_refs: Vec<&DrawShape> = layer_shapes
10696            .take(self.shape_batch_limits.max_shapes_per_batch)
10697            .collect();
10698        let shape_count = shape_refs.len();
10699        if shape_count == 0 {
10700            return None;
10701        }
10702
10703        // Per-shape gradient spans as a prefix sum, so every output slot is
10704        // known before conversion starts and the shapes can convert in
10705        // parallel into disjoint sub-slices.
10706        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
10707        let mut total_gradient_stops = 0u32;
10708        gradient_offsets.push(0);
10709        for shape in &shape_refs {
10710            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
10711            gradient_offsets.push(total_gradient_stops);
10712        }
10713
10714        self.scratch_shape_data.clear();
10715        self.scratch_shape_data
10716            .resize(shape_count, ShapeData::zeroed());
10717        self.scratch_gradients.clear();
10718        self.scratch_gradients
10719            .resize(total_gradient_stops as usize, GradientStop::zeroed());
10720
10721        convert_shapes_into_outputs(
10722            &shape_refs,
10723            brushes,
10724            &gradient_offsets,
10725            root_scale,
10726            &mut self.scratch_shape_data,
10727            &mut self.scratch_gradients,
10728        );
10729        #[cfg(not(target_arch = "wasm32"))]
10730        {
10731            if fill_area_diag_enabled() {
10732                self.fill_area_diag
10733                    .add_shape_quads(&self.scratch_shape_data, viewport);
10734            }
10735        }
10736
10737        #[cfg(not(target_arch = "wasm32"))]
10738        {
10739            self.shape_buffers.ensure_capacity(
10740                &self.device,
10741                &self.shape_bind_group_layout,
10742                &self.identity_similarity_buffer,
10743                self.dummy_paint_buffer.as_ref(),
10744                shape_count,
10745                self.scratch_gradients.len().max(1),
10746            );
10747            self.stage_viewport_uniforms(staged_uploads, viewport);
10748            staged_uploads.stage(
10749                UploadTarget::ShapeData,
10750                bytemuck::cast_slice(&self.scratch_shape_data),
10751            );
10752            if !self.scratch_gradients.is_empty() {
10753                staged_uploads.stage(
10754                    UploadTarget::ShapeGradient,
10755                    bytemuck::cast_slice(&self.scratch_gradients),
10756                );
10757            }
10758        }
10759
10760        #[cfg(target_arch = "wasm32")]
10761        let shape_slot = {
10762            let slot = self.claim_wasm_shape_batch();
10763            {
10764                let buffers = &mut self.wasm_shape_batches[slot];
10765                buffers.ensure_capacity(
10766                    &self.device,
10767                    &self.shape_bind_group_layout,
10768                    &self.identity_similarity_buffer,
10769                    self.dummy_paint_buffer.as_ref(),
10770                    shape_count,
10771                    self.scratch_gradients.len().max(1),
10772                );
10773            }
10774            let buffers = &self.wasm_shape_batches[slot];
10775            self.write_wasm_buffer(
10776                &buffers.shape_buffer,
10777                bytemuck::cast_slice(&self.scratch_shape_data),
10778            );
10779            if !self.scratch_gradients.is_empty() {
10780                self.write_wasm_buffer(
10781                    &buffers.gradient_buffer,
10782                    bytemuck::cast_slice(&self.scratch_gradients),
10783                );
10784            }
10785            slot
10786        };
10787
10788        #[cfg(target_arch = "wasm32")]
10789        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
10790
10791        Some(PreparedShapeBatch {
10792            vertex_start: 0,
10793            vertex_count: shape_count as u32 * 6,
10794            has_gradient: total_gradient_stops > 0,
10795            #[cfg(target_arch = "wasm32")]
10796            shape_slot,
10797            #[cfg(target_arch = "wasm32")]
10798            uniform_slot,
10799        })
10800    }
10801
10802    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
10803    /// mapped regions of the frame upload buffer instead of scratch vectors —
10804    /// one CPU pass over the data instead of three (convert, stage, upload).
10805    /// Returns the prepared batch and the upload-buffer base offset to pass
10806    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
10807    /// `staged_uploads` while its byte blob stays empty.
10808    #[cfg(not(target_arch = "wasm32"))]
10809    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
10810        &mut self,
10811        frame_encoder: &mut C,
10812        layer_shapes: I,
10813        brushes: &[Brush],
10814        root_scale: f32,
10815        viewport: ViewportUniformParams,
10816        staged_uploads: &mut StagedBufferUploads,
10817    ) -> Option<(PreparedShapeBatch, u64)>
10818    where
10819        I: Iterator<Item = &'a DrawShape>,
10820    {
10821        let shape_refs: Vec<&DrawShape> = layer_shapes
10822            .take(self.shape_batch_limits.max_shapes_per_batch)
10823            .collect();
10824        let shape_count = shape_refs.len();
10825        if shape_count == 0 {
10826            return None;
10827        }
10828
10829        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
10830        let mut total_gradient_stops = 0u32;
10831        gradient_offsets.push(0);
10832        for shape in &shape_refs {
10833            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
10834            gradient_offsets.push(total_gradient_stops);
10835        }
10836
10837        self.shape_buffers.ensure_capacity(
10838            &self.device,
10839            &self.shape_bind_group_layout,
10840            &self.identity_similarity_buffer,
10841            self.dummy_paint_buffer.as_ref(),
10842            shape_count,
10843            (total_gradient_stops as usize).max(1),
10844        );
10845
10846        self.scratch_shape_data.clear();
10847        self.scratch_shape_data
10848            .resize(shape_count, ShapeData::zeroed());
10849        self.scratch_gradients.clear();
10850        self.scratch_gradients
10851            .resize(total_gradient_stops as usize, GradientStop::zeroed());
10852        convert_shapes_into_outputs(
10853            &shape_refs,
10854            brushes,
10855            &gradient_offsets,
10856            root_scale,
10857            &mut self.scratch_shape_data,
10858            &mut self.scratch_gradients,
10859        );
10860        if fill_area_diag_enabled() {
10861            self.fill_area_diag
10862                .add_shape_quads(&self.scratch_shape_data, viewport);
10863        }
10864
10865        // Region layout inside the frame upload buffer. Every element type is
10866        // f32/u32-based, so all lengths are multiples of
10867        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
10868        // copy-aligned. Writing each scratch slice straight into the upload
10869        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
10870        // over the batch payload).
10871        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
10872        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
10873        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
10874        let total_len = uniform_len + shape_len + gradient_len;
10875        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
10876        self.ensure_upload_buffer_capacity(upload_base + total_len);
10877
10878        let shape_off = uniform_len;
10879        let gradient_off = shape_off + shape_len;
10880
10881        let uniforms = Self::viewport_uniforms(viewport);
10882        let mut upload_stats = self.frame_graph_executor.upload_buffer(
10883            &self.queue,
10884            &self.upload_buffer,
10885            upload_base,
10886            bytemuck::bytes_of(&uniforms),
10887        );
10888        upload_stats.upload_bytes += self
10889            .frame_graph_executor
10890            .upload_buffer(
10891                &self.queue,
10892                &self.upload_buffer,
10893                upload_base + shape_off,
10894                bytemuck::cast_slice(&self.scratch_shape_data),
10895            )
10896            .upload_bytes;
10897        if !self.scratch_gradients.is_empty() {
10898            upload_stats.upload_bytes += self
10899                .frame_graph_executor
10900                .upload_buffer(
10901                    &self.queue,
10902                    &self.upload_buffer,
10903                    upload_base + gradient_off,
10904                    bytemuck::cast_slice(&self.scratch_gradients),
10905                )
10906                .upload_bytes;
10907        }
10908        self.frame_stats.record_command_stats(upload_stats);
10909
10910        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
10911        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
10912        staged_uploads.record_upload_copy(
10913            UploadTarget::ShapeGradient,
10914            gradient_off,
10915            0,
10916            gradient_len,
10917        );
10918
10919        Some((
10920            PreparedShapeBatch {
10921                vertex_start: 0,
10922                vertex_count: shape_count as u32 * 6,
10923                has_gradient: total_gradient_stops > 0,
10924            },
10925            upload_base,
10926        ))
10927    }
10928
10929    /// Whether retained replay batches can exist on this device: they bind
10930    /// unsized buffers, so they ride the storage-buffer batch mode only.
10931    /// Always `false` on wasm, which has no retained replay path — the
10932    /// method exists on both arches so the packet producer has one
10933    /// architecture.
10934    pub(crate) fn replay_supported(&self) -> bool {
10935        // Deliberately not conditioned on free slot ids: an exhausted pool
10936        // only means new captures fail (handled per capture), while flipping
10937        // this bit would retire every live feed slot.
10938        #[cfg(target_arch = "wasm32")]
10939        {
10940            false
10941        }
10942        #[cfg(not(target_arch = "wasm32"))]
10943        {
10944            self.shape_batch_limits.storage
10945        }
10946    }
10947
10948    /// Return the planner-drained ack confirmations buffer (capacity
10949    /// intact) to the store after the producer applied a frame's
10950    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
10951    /// P4b no-allocation contract, closed by the caller now that ack
10952    /// application lives producer-side. No-op on wasm.
10953    pub(crate) fn restore_replay_ack_confirmations(
10954        &mut self,
10955        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
10956    ) {
10957        #[cfg(not(target_arch = "wasm32"))]
10958        {
10959            self.replay_ack_confirmations = confirmations;
10960        }
10961        #[cfg(target_arch = "wasm32")]
10962        let _ = confirmations;
10963    }
10964
10965    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
10966    /// the plan's releases, then honors its capture requests against the
10967    /// scene they were recorded for, answering with a [`ReplayAck`] of
10968    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
10969    /// for recycling. This is the store half of the split — it touches NO
10970    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
10971    /// release, and age eviction all live in the planner
10972    /// (`take_frame_ops`/`apply_ack`).
10973    ///
10974    /// Ordering is what makes slot release safe: a slot the plan releases
10975    /// is never referenced by a retained op of the same frame (misses
10976    /// release before their op would have been pushed, and rebuild frames
10977    /// release at flush start), so freeing it here — before any encoding —
10978    /// cannot orphan a draw.
10979    #[cfg(not(target_arch = "wasm32"))]
10980    fn consume_replay_ops(
10981        &mut self,
10982        mut ops: crate::frame_packet::ReplayFrameOps,
10983        shapes: &[DrawShape],
10984        brushes: &[Brush],
10985        root_scale: f32,
10986    ) -> (
10987        crate::frame_packet::ReplayAck,
10988        crate::frame_packet::ReplayFrameOps,
10989    ) {
10990        if ops.generation < self.store_feed_generation {
10991            // Fail-closed: ops planned under an OLDER slot universe name
10992            // slots this store does not hold. Drop the batch whole —
10993            // captures unconfirmed self-heal (the planner never serves
10994            // them), and stale releases must not free live ids.
10995            // Synchronously impossible today; structural for the split.
10996            self.replay_generation_drops += 1;
10997            log::warn!(
10998                "[command-feed] dropping replay ops of generation {} against store \
10999                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
11000                ops.generation,
11001                self.store_feed_generation,
11002                ops.captures.len(),
11003                ops.color_patches.len(),
11004                ops.releases.len(),
11005                self.replay_generation_drops,
11006            );
11007            ops.captures.clear();
11008            ops.color_patches.clear();
11009            ops.releases.clear();
11010            return (
11011                crate::frame_packet::ReplayAck {
11012                    generation: self.store_feed_generation,
11013                    confirmations: Vec::new(),
11014                },
11015                ops,
11016            );
11017        }
11018        if ops.generation > self.store_feed_generation {
11019            // Adopt forward: a producer-side bump (scale change,
11020            // `retire_feed`) delivers its whole retirement — the releases
11021            // for every retired slot — THROUGH this very batch, so a
11022            // higher generation is the new universe arriving, not a stale
11023            // one. The store follows the producer's authority; it never
11024            // reads the producer's thread-local.
11025            self.store_feed_generation = ops.generation;
11026        }
11027        let generation = ops.generation;
11028        // Queued releases free first, so their buffers are available before
11029        // this frame's captures ask.
11030        for slot in ops.releases.drain(..) {
11031            self.release_replay_slot(slot);
11032        }
11033        // `take` leaves `Vec::new()` behind (no allocation); the render
11034        // loop restores the vec after the planner drains the ack.
11035        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
11036        debug_assert!(confirmations.is_empty());
11037        for capture in ops.captures.drain(..) {
11038            if capture.frame != ops.frame {
11039                // Defensive: a capture that outlived its frame references
11040                // shape indices of a scene that never rendered; honoring it
11041                // against THIS frame's shapes would retain wrong content
11042                // under a confirmed identity. Categorically drop it. Should
11043                // never fire now that ops travel inside the frame's own
11044                // packet.
11045                log::warn!(
11046                    "[command-feed] dropping stale capture for slot {} of {:?} \
11047                     (queued frame {}, ops frame {})",
11048                    capture.key.1,
11049                    capture.key.0,
11050                    capture.frame,
11051                    ops.frame,
11052                );
11053                continue;
11054            }
11055            let end = capture.shape_start + capture.shape_count;
11056            let Some(slice) = shapes.get(capture.shape_start..end) else {
11057                continue;
11058            };
11059            let refs: Vec<&DrawShape> = slice.iter().collect();
11060            let Some(gpu_slot) = self.capture_replay_slot(&refs, brushes, root_scale) else {
11061                continue;
11062            };
11063            confirmations.push((capture.key, gpu_slot));
11064        }
11065        // Park the frame's recolor patches for the retained prepare arms
11066        // (`stage_replay_patches`); the vec swapped out is last frame's,
11067        // already drained empty, and returns to the producer with the ack.
11068        // The defensive clear only bites when no prepare arm ran last
11069        // frame (aborted render): those patches targeted a frame that
11070        // never encoded, and their spans re-queue fresh recolors each
11071        // served frame.
11072        self.replay_color_patches.clear();
11073        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
11074        (
11075            crate::frame_packet::ReplayAck {
11076                generation,
11077                confirmations,
11078            },
11079            ops,
11080        )
11081    }
11082
11083    /// Test/diagnostic view of the store's lifetime count of replay-ops
11084    /// batches dropped whole by the generation check — the consume gate's
11085    /// proof that Surface frames (default plans, generation 0) are never
11086    /// fed to the store.
11087    #[cfg(not(target_arch = "wasm32"))]
11088    pub(crate) fn replay_generation_drops(&self) -> u64 {
11089        self.replay_generation_drops
11090    }
11091
11092    /// Test hook for the message protocol: runs one planner→store→planner
11093    /// replay cycle outside a frame, with the batch stamped
11094    /// `store_feed_generation + generation_skew`, and returns how many
11095    /// captures the store confirmed. A skew that lands BELOW the store's
11096    /// generation manufactures the fail-closed drop; a skew above it
11097    /// exercises adopt-forward. Both are synchronously impossible through
11098    /// the public render path today.
11099    #[cfg(not(target_arch = "wasm32"))]
11100    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
11101        let generation = self.store_feed_generation.wrapping_add(generation_skew);
11102        let ops = crate::shape_replay::SHAPE_REPLAY
11103            .with(|state| state.borrow_mut().take_frame_ops(generation));
11104        let (ack, recycled) = self.consume_replay_ops(ops, &[], &[], 1.0);
11105        let confirmed = ack.confirmations.len();
11106        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
11107            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
11108        confirmed
11109    }
11110
11111    /// Stages every queued replay recolor patch. Feed recolors are always
11112    /// solid, so every patch rewrites the shape's 16-byte record in the
11113    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
11114    /// a recolored frame uploads colors, not geometry. Runs in the retained
11115    /// prepare arms so the writes land in the same staged-upload flush that
11116    /// carries the frame's transforms; draining is idempotent across arms.
11117    #[cfg(not(target_arch = "wasm32"))]
11118    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
11119        // Capacity-retaining drain: swap the frame's parked patch buffer
11120        // (see `consume_replay_ops`) against the scratch arena instead of
11121        // `mem::take`, so both keep their high-water capacity across
11122        // frames. The scratch is cleared before every return, which
11123        // preserves drain idempotence across the retained prepare arms: a
11124        // later drain in the same frame swaps one empty-with-capacity
11125        // arena for another and stages nothing.
11126        std::mem::swap(
11127            &mut self.replay_color_patches,
11128            &mut self.color_patch_scratch,
11129        );
11130        let total_patches = self.color_patch_scratch.len();
11131        if total_patches == 0 {
11132            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
11133            return;
11134        }
11135
11136        // Patches land in the slot's CPU mirror and upload as one contiguous
11137        // span per slot. Uploading each patch individually would record one
11138        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
11139        // dots a frame — that many commands stall a mobile GPU for longer
11140        // than the spans' untouched bytes ever cost.
11141        #[derive(Clone, Copy)]
11142        struct DirtySpan {
11143            paint_min: u32,
11144            paint_max: u32,
11145        }
11146        const CLEAN: DirtySpan = DirtySpan {
11147            paint_min: u32::MAX,
11148            paint_max: 0,
11149        };
11150        let mut dirty: std::collections::HashMap<
11151            u32,
11152            DirtySpan,
11153            cranpose_ui_graphics::FxBuildHasher,
11154        > = std::collections::HashMap::default();
11155
11156        // One bare 16-byte write into the slot's paint mirror per patch.
11157        for patch in &self.color_patch_scratch {
11158            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
11159                continue;
11160            };
11161            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
11162                continue;
11163            };
11164            *paint = patch.color;
11165            let span = dirty.entry(patch.slot).or_insert(CLEAN);
11166            span.paint_min = span.paint_min.min(patch.shape_index);
11167            span.paint_max = span.paint_max.max(patch.shape_index);
11168        }
11169
11170        let mut uploaded_records = 0u64;
11171        let mut uploaded_bytes = 0u64;
11172        let slots_touched = dirty.len() as u64;
11173        for (slot_id, span) in dirty {
11174            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
11175                continue;
11176            };
11177            if span.paint_min <= span.paint_max {
11178                let range = span.paint_min as usize..span.paint_max as usize + 1;
11179                uploaded_records += range.len() as u64;
11180                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
11181                staged_uploads.stage_at(
11182                    UploadTarget::ReplayPaintData(slot_id),
11183                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
11184                    bytemuck::cast_slice(&slot.paint_mirror[range]),
11185                );
11186            }
11187        }
11188        // A patched color is one 16-byte vec4; the staged bytes exceed this
11189        // only by the untouched records inside each coalesced span.
11190        let ideal_bytes = total_patches as u64 * 16;
11191        self.replay_upload_stats.note_frame(
11192            total_patches as u64,
11193            slots_touched,
11194            uploaded_records,
11195            uploaded_bytes,
11196            ideal_bytes,
11197        );
11198        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
11199            log::warn!(
11200                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
11201                 across {} slots (color-only {:.1} KB)",
11202                total_patches,
11203                uploaded_records,
11204                uploaded_bytes as f64 / 1024.0,
11205                slots_touched,
11206                ideal_bytes as f64 / 1024.0,
11207            );
11208        }
11209        self.color_patch_scratch.clear();
11210    }
11211
11212    /// Converts `shape_refs` once and retains the result on the GPU as a
11213    /// replay slot. Returns the slot id the scene's retained draws reference.
11214    #[cfg(not(target_arch = "wasm32"))]
11215    pub(crate) fn capture_replay_slot(
11216        &mut self,
11217        shape_refs: &[&DrawShape],
11218        brushes: &[Brush],
11219        root_scale: f32,
11220    ) -> Option<u32> {
11221        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
11222            return None;
11223        }
11224        let id = self.replay_slots.free_ids.pop()?;
11225        let shape_count = shape_refs.len();
11226
11227        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
11228        let mut total_gradient_stops = 0u32;
11229        gradient_offsets.push(0);
11230        for shape in shape_refs {
11231            total_gradient_stops += shape_gradient_stop_count(shape, brushes) as u32;
11232            gradient_offsets.push(total_gradient_stops);
11233        }
11234
11235        let mut shape_data = vec![ShapeData::zeroed(); shape_count];
11236        let mut gradients = vec![GradientStop::zeroed(); (total_gradient_stops as usize).max(1)];
11237        convert_shapes_into_outputs(
11238            shape_refs,
11239            brushes,
11240            &gradient_offsets,
11241            root_scale,
11242            &mut shape_data,
11243            &mut gradients,
11244        );
11245
11246        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11247            label: Some("Replay Shape Buffer"),
11248            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
11249            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
11250            mapped_at_creation: true,
11251        });
11252        shape_buffer
11253            .slice(..)
11254            .get_mapped_range_mut()
11255            .copy_from_slice(bytemuck::cast_slice(&shape_data));
11256        shape_buffer.unmap();
11257
11258        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11259            label: Some("Replay Gradient Buffer"),
11260            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
11261            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
11262            mapped_at_creation: true,
11263        });
11264        gradient_buffer
11265            .slice(..)
11266            .get_mapped_range_mut()
11267            .copy_from_slice(bytemuck::cast_slice(&gradients));
11268        gradient_buffer.unmap();
11269
11270        // Filled by the mesh arm when the capture keeps its arc mesh, so
11271        // the fill-diag records can price those shapes by their true
11272        // triangle area.
11273        let mut mesh_fill_records: Option<Vec<FillDiagShapeRecord>> = None;
11274        let mesh = if arc_mesh_enabled() {
11275            match build_arc_mesh_vertices(&shape_data) {
11276                Some(build) => {
11277                    let cut = if build.quad_area > 0.0 {
11278                        (1.0 - build.mesh_area / build.quad_area) * 100.0
11279                    } else {
11280                        0.0
11281                    };
11282                    // Always-on warn: `log::info` is invisible on the desktop
11283                    // console, and captures are rare — one line per slot
11284                    // lifetime. The unique-vert/index counts against the
11285                    // six-per-shape quad baseline are the vertex-amplification
11286                    // instrument P1b exists for.
11287                    log::warn!(
11288                        "[arc-mesh] slot {id}: {} arcs meshed ({} segs), {} passthrough; \
11289                         {} unique verts / {} indices (quad path: {} verts); \
11290                         quad_px {:.0} -> mesh_px {:.0} (-{:.1}%)",
11291                        build.meshed_arcs,
11292                        build.meshed_segments,
11293                        build.passthrough,
11294                        build.vertices.len(),
11295                        build.indices.len(),
11296                        shape_count * 6,
11297                        build.quad_area,
11298                        build.mesh_area,
11299                        cut,
11300                    );
11301                    if build.meshed_arcs > 0 && fill_area_diag_enabled() {
11302                        mesh_fill_records = Some(fill_diag_capture_records(
11303                            &shape_data,
11304                            Some((&build.vertices, &build.indices, &build.index_prefix)),
11305                        ));
11306                    }
11307                    // A slot that meshed nothing gains nothing over the
11308                    // indexless quad path — skip the buffers.
11309                    (build.meshed_arcs > 0).then(|| {
11310                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11311                            label: Some("Replay Mesh Vertex Buffer"),
11312                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
11313                            usage: wgpu::BufferUsages::VERTEX,
11314                            mapped_at_creation: true,
11315                        });
11316                        vertex_buffer
11317                            .slice(..)
11318                            .get_mapped_range_mut()
11319                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
11320                        vertex_buffer.unmap();
11321                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11322                            label: Some("Replay Mesh Index Buffer"),
11323                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
11324                            usage: wgpu::BufferUsages::INDEX,
11325                            mapped_at_creation: true,
11326                        });
11327                        index_buffer
11328                            .slice(..)
11329                            .get_mapped_range_mut()
11330                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
11331                        index_buffer.unmap();
11332                        ReplaySlotMesh {
11333                            vertex_buffer,
11334                            index_buffer,
11335                            index_prefix: build.index_prefix,
11336                        }
11337                    })
11338                }
11339                None => {
11340                    log::warn!(
11341                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
11342                         {shape_count} shapes; whole slot falls back to quad passthrough"
11343                    );
11344                    None
11345                }
11346            }
11347        } else {
11348            None
11349        };
11350
11351        let fill_diag_shapes = if fill_area_diag_enabled() {
11352            let records =
11353                mesh_fill_records.unwrap_or_else(|| fill_diag_capture_records(&shape_data, None));
11354            // Feed the once-per-process top-slack dump before the records
11355            // move into the slot.
11356            self.fill_area_diag.note_retained_capture(id, &records);
11357            records
11358        } else {
11359            Vec::new()
11360        };
11361
11362        // Seed the mutable paint from the converted colors, so an unpatched
11363        // replay renders bit-identically to the capture frame.
11364        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
11365        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
11366            label: Some("Replay Paint Buffer"),
11367            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
11368            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
11369            mapped_at_creation: true,
11370        });
11371        paint_buffer
11372            .slice(..)
11373            .get_mapped_range_mut()
11374            .copy_from_slice(bytemuck::cast_slice(&paint));
11375        paint_buffer.unmap();
11376
11377        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
11378            label: Some("Replay Shape Bind Group"),
11379            layout: &self.shape_bind_group_layout,
11380            entries: &[
11381                wgpu::BindGroupEntry {
11382                    binding: 0,
11383                    resource: shape_buffer.as_entire_binding(),
11384                },
11385                wgpu::BindGroupEntry {
11386                    binding: 1,
11387                    resource: gradient_buffer.as_entire_binding(),
11388                },
11389                // The transform slot is selected per draw via the dynamic
11390                // offset, so retained draws sharing this capture can each
11391                // move independently.
11392                wgpu::BindGroupEntry {
11393                    binding: 2,
11394                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11395                        buffer: &self.replay_slots.transform_buffer,
11396                        offset: 0,
11397                        size: Some(
11398                            std::num::NonZeroU64::new(
11399                                std::mem::size_of::<SimilarityTransform>() as u64
11400                            )
11401                            .expect("similarity transform is non-empty"),
11402                        ),
11403                    }),
11404                },
11405                wgpu::BindGroupEntry {
11406                    binding: 3,
11407                    resource: paint_buffer.as_entire_binding(),
11408                },
11409            ],
11410        });
11411
11412        let capture_epoch = self.replay_slots.next_capture_epoch;
11413        self.replay_slots.next_capture_epoch += 1;
11414        self.replay_slots.slots.insert(
11415            id,
11416            ReplaySlot {
11417                paint_buffer,
11418                bind_group,
11419                shape_count: shape_count as u32,
11420                paint_mirror: paint,
11421                mesh,
11422                capture_epoch,
11423                has_gradient: total_gradient_stops > 0,
11424                fill_diag_shapes,
11425            },
11426        );
11427        Some(id)
11428    }
11429
11430    /// Frees a replay slot's GPU resources and returns its id to the pool.
11431    #[cfg(not(target_arch = "wasm32"))]
11432    pub(crate) fn release_replay_slot(&mut self, id: u32) {
11433        if self.replay_slots.slots.remove(&id).is_some() {
11434            self.replay_slots.free_ids.push(id);
11435            // A cached bundle keeps references on the slot buffers it binds.
11436            // The epoch in each key already makes entries for this capture
11437            // unreachable — releases are rare (churn, retire_feed), so drop
11438            // the whole cache and free those references now rather than one
11439            // frame later through eviction.
11440            self.retained_bundle_cache.clear();
11441        }
11442    }
11443
11444    /// Test/diagnostic view of the latched instanced-quad selection: `true`
11445    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
11446    #[cfg(not(target_arch = "wasm32"))]
11447    #[doc(hidden)]
11448    pub fn instanced_quads_active(&self) -> bool {
11449        self.instanced_quads.is_some()
11450    }
11451
11452    /// Test/diagnostic view of retained arc meshes: how many live replay
11453    /// slots hold a mesh, out of all live slots.
11454    #[cfg(not(target_arch = "wasm32"))]
11455    #[doc(hidden)]
11456    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
11457        let meshed = self
11458            .replay_slots
11459            .slots
11460            .values()
11461            .filter(|slot| slot.mesh.is_some())
11462            .count();
11463        (meshed, self.replay_slots.slots.len())
11464    }
11465
11466    /// Draws one retained replay batch — `retained`'s shape range of its
11467    /// slot's capture, under the transform staged for this draw's index (see
11468    /// the retained arms of the segment paths).
11469    #[cfg(not(target_arch = "wasm32"))]
11470    fn draw_retained_batch(
11471        &self,
11472        render_pass: &mut wgpu::RenderPass<'_>,
11473        retained: &RetainedDraw,
11474        retained_index: usize,
11475        width: u32,
11476        height: u32,
11477    ) {
11478        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
11479            return;
11480        };
11481        if retained_index as u32 >= MAX_REPLAY_SLOTS {
11482            return;
11483        }
11484        let first = retained.first_shape.min(slot.shape_count);
11485        let last = retained
11486            .first_shape
11487            .saturating_add(retained.shape_count)
11488            .min(slot.shape_count);
11489        if first >= last {
11490            return;
11491        }
11492        if fill_area_diag_enabled() {
11493            self.fill_area_diag.add_retained_range(
11494                &slot.fill_diag_shapes,
11495                first,
11496                last,
11497                &retained.transform,
11498            );
11499        }
11500        self.frame_stats.bump_shapes();
11501        self.frame_stats.add_draw_calls(1);
11502        render_pass.set_scissor_rect(0, 0, width, height);
11503        // A captured mesh replaces the six-per-shape quad expansion with the
11504        // slot's conservative arc mesh — same bind groups, same SrcOver
11505        // blend, one draw per op over the identical shape range, so z order
11506        // is untouched either way. Slots without a mesh draw through the
11507        // latched instanced-quad path when it exists (four vertex executions
11508        // per shape, shape index from the instance index), else the plain
11509        // six-vertex expansion.
11510        let mesh = slot.mesh.as_ref().map(|mesh| (mesh, self.mesh_pipeline()));
11511        match &mesh {
11512            Some((_, mesh_pipeline)) => render_pass.set_pipeline(mesh_pipeline),
11513            None => match &self.instanced_quads {
11514                Some(instanced) if !slot.has_gradient => {
11515                    render_pass.set_pipeline(self.instanced_pipeline_solid(instanced))
11516                }
11517                Some(instanced) => {
11518                    render_pass.set_pipeline(self.instanced_pipeline(instanced, BlendMode::SrcOver))
11519                }
11520                None if !slot.has_gradient => render_pass.set_pipeline(self.shape_pipeline_solid()),
11521                None => render_pass.set_pipeline(self.shape_pipeline(BlendMode::SrcOver)),
11522            },
11523        }
11524        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
11525        render_pass.set_bind_group(
11526            1,
11527            &slot.bind_group,
11528            &[retained_index as u32 * REPLAY_TRANSFORM_STRIDE as u32],
11529        );
11530        match mesh {
11531            Some((mesh, _)) => {
11532                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
11533                render_pass
11534                    .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
11535                render_pass.draw_indexed(
11536                    mesh.index_prefix[first as usize]..mesh.index_prefix[last as usize],
11537                    0,
11538                    0..1,
11539                );
11540            }
11541            None => match &self.instanced_quads {
11542                Some(instanced) => {
11543                    render_pass.set_index_buffer(
11544                        instanced.index_buffer.slice(..),
11545                        wgpu::IndexFormat::Uint16,
11546                    );
11547                    render_pass.draw_indexed(0..6, 0, first..last);
11548                }
11549                None => render_pass.draw(first * 6..last * 6, 0..1),
11550            },
11551        }
11552    }
11553
11554    /// Key of the retained stretch at `item_range`: one op key per resolved
11555    /// retained item, in draw order, carrying exactly the state that decides
11556    /// the commands [`Self::draw_retained_batch`] would encode for it —
11557    /// clamped range, dynamic-offset index, mesh-vs-quad pipeline choice,
11558    /// and the slot's capture epoch (`None` while the slot is absent, when
11559    /// the op draws nothing on the direct path too).
11560    #[cfg(not(target_arch = "wasm32"))]
11561    fn retained_bundle_key(
11562        &self,
11563        ordered_items: &[(usize, SegmentDrawItem)],
11564        retained_draws: &[RetainedDraw],
11565        item_range: Range<usize>,
11566    ) -> RetainedBundleKey {
11567        let mut ops = Vec::with_capacity(item_range.len());
11568        for (_, item) in &ordered_items[item_range] {
11569            let SegmentDrawItem::Retained(index) = item else {
11570                continue;
11571            };
11572            let Some(retained) = retained_draws.get(*index) else {
11573                continue;
11574            };
11575            let slot = self.replay_slots.slots.get(&retained.slot);
11576            let (first, last) = match slot {
11577                Some(slot) => (
11578                    retained.first_shape.min(slot.shape_count),
11579                    retained
11580                        .first_shape
11581                        .saturating_add(retained.shape_count)
11582                        .min(slot.shape_count),
11583                ),
11584                None => (
11585                    retained.first_shape,
11586                    retained.first_shape.saturating_add(retained.shape_count),
11587                ),
11588            };
11589            ops.push(RetainedBundleOpKey {
11590                slot: retained.slot,
11591                capture_epoch: slot.map(|slot| slot.capture_epoch),
11592                first,
11593                last,
11594                retained_index: *index as u32,
11595                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
11596                    && self.shape_batch_limits.storage,
11597            });
11598        }
11599        RetainedBundleKey { ops }
11600    }
11601
11602    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
11603    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
11604    /// scissor reset (bundles cannot set scissor; the caller sets the same
11605    /// full-target scissor on the pass before executing). Must only be
11606    /// called with a key built this frame, so every op with an epoch still
11607    /// resolves to its slot.
11608    #[cfg(not(target_arch = "wasm32"))]
11609    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
11610        let mut encoder =
11611            self.device
11612                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
11613                    label: Some("Retained Stretch Bundle"),
11614                    // Every fused-pass target — the swapchain, screenshot
11615                    // textures, pooled layer surfaces — is created with the
11616                    // renderer's one surface format.
11617                    color_formats: &[Some(self.surface_format)],
11618                    depth_stencil: None,
11619                    sample_count: 1,
11620                    multiview: None,
11621                });
11622        for op in &key.ops {
11623            if op.capture_epoch.is_none()
11624                || op.retained_index >= MAX_REPLAY_SLOTS
11625                || op.first >= op.last
11626            {
11627                continue;
11628            }
11629            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
11630                continue;
11631            };
11632            let mesh = slot.mesh.as_ref().map(|mesh| (mesh, self.mesh_pipeline()));
11633            match &mesh {
11634                Some((_, mesh_pipeline)) => encoder.set_pipeline(mesh_pipeline),
11635                // The solid-vs-gradient choice is fixed per capture, and the
11636                // op key already carries the capture epoch, so a cached
11637                // bundle can never encode a stale pipeline for a slot id.
11638                None => match &self.instanced_quads {
11639                    Some(instanced) if !slot.has_gradient => {
11640                        encoder.set_pipeline(self.instanced_pipeline_solid(instanced))
11641                    }
11642                    Some(instanced) => {
11643                        encoder.set_pipeline(self.instanced_pipeline(instanced, BlendMode::SrcOver))
11644                    }
11645                    None if !slot.has_gradient => encoder.set_pipeline(self.shape_pipeline_solid()),
11646                    None => encoder.set_pipeline(self.shape_pipeline(BlendMode::SrcOver)),
11647                },
11648            }
11649            encoder.set_bind_group(0, &self.uniform_bind_group, &[]);
11650            encoder.set_bind_group(
11651                1,
11652                &slot.bind_group,
11653                &[op.retained_index * REPLAY_TRANSFORM_STRIDE as u32],
11654            );
11655            match mesh {
11656                Some((mesh, _)) => {
11657                    encoder.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
11658                    encoder
11659                        .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
11660                    encoder.draw_indexed(
11661                        mesh.index_prefix[op.first as usize]..mesh.index_prefix[op.last as usize],
11662                        0,
11663                        0..1,
11664                    );
11665                }
11666                // The latched selection is a per-renderer constant, so it
11667                // needs no place in `RetainedBundleOpKey` — every cached
11668                // bundle in this renderer's lifetime encodes the same choice
11669                // the direct path makes.
11670                None => match &self.instanced_quads {
11671                    Some(instanced) => {
11672                        encoder.set_index_buffer(
11673                            instanced.index_buffer.slice(..),
11674                            wgpu::IndexFormat::Uint16,
11675                        );
11676                        encoder.draw_indexed(0..6, 0, op.first..op.last);
11677                    }
11678                    None => encoder.draw(op.first * 6..op.last * 6, 0..1),
11679                },
11680            }
11681        }
11682        encoder.finish(&wgpu::RenderBundleDescriptor {
11683            label: Some("Retained Stretch Bundle"),
11684        })
11685    }
11686
11687    /// Draws one maximal consecutive retained stretch through the bundle
11688    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
11689    /// range or count change, slot release), then execute the cached bundle.
11690    /// Replays byte-identical commands to the per-op direct path.
11691    /// `stage_replay_patches` and the per-frame transform staging stay in
11692    /// the prepare arms, untouched — bundles bind buffers whose contents are
11693    /// read at execution.
11694    #[cfg(not(target_arch = "wasm32"))]
11695    fn draw_retained_stretch_bundled(
11696        &mut self,
11697        render_pass: &mut wgpu::RenderPass<'_>,
11698        ordered_items: &[(usize, SegmentDrawItem)],
11699        retained_draws: &[RetainedDraw],
11700        item_range: Range<usize>,
11701        width: u32,
11702        height: u32,
11703    ) {
11704        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
11705        if !self.retained_bundle_cache.hit(&key) {
11706            let bundle = self.build_retained_bundle(&key);
11707            self.retained_bundle_cache.insert(key.clone(), bundle);
11708        }
11709        // Mirror the direct path's per-op stats for every op the bundle
11710        // draws, so bundling is invisible to the frame counters.
11711        for op in &key.ops {
11712            if op.capture_epoch.is_some()
11713                && op.retained_index < MAX_REPLAY_SLOTS
11714                && op.first < op.last
11715            {
11716                self.frame_stats.bump_shapes();
11717                self.frame_stats.add_draw_calls(1);
11718                if fill_area_diag_enabled() {
11719                    // Mirror the direct path's fill accounting per bundled op.
11720                    let slot = self.replay_slots.slots.get(&op.slot);
11721                    let retained = retained_draws.get(op.retained_index as usize);
11722                    if let (Some(slot), Some(retained)) = (slot, retained) {
11723                        self.fill_area_diag.add_retained_range(
11724                            &slot.fill_diag_shapes,
11725                            op.first,
11726                            op.last,
11727                            &retained.transform,
11728                        );
11729                    }
11730                }
11731            }
11732        }
11733        // Bundles inherit the pass scissor: set the same full-target rect
11734        // the direct path sets before every retained draw. Executing the
11735        // bundle then resets pipeline/bind/vertex state, which is harmless —
11736        // every following fused arm re-binds its own.
11737        render_pass.set_scissor_rect(0, 0, width, height);
11738        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
11739            render_pass.execute_bundles(std::iter::once(bundle));
11740        }
11741    }
11742
11743    /// Test/diagnostic view of the retained bundle cache: lifetime
11744    /// (rebuilds, cached executes).
11745    #[cfg(not(target_arch = "wasm32"))]
11746    #[doc(hidden)]
11747    pub fn retained_bundle_stats(&self) -> (u64, u64) {
11748        self.retained_bundle_cache.stats()
11749    }
11750
11751    /// Test/diagnostic view of the transient rim mesh path: lifetime count
11752    /// of rims drawn as band meshes instead of full bounding quads.
11753    #[cfg(not(target_arch = "wasm32"))]
11754    #[doc(hidden)]
11755    pub fn rim_meshes_emitted(&self) -> u64 {
11756        self.rim_meshes_emitted
11757    }
11758
11759    /// Test/diagnostic view of the static leading-span cache: lifetime
11760    /// (hits, recaptures).
11761    pub fn static_span_stats(&self) -> (u64, u64) {
11762        (self.static_span.hits, self.static_span.recaptures)
11763    }
11764
11765    /// Uploads the region of the transient rim mesh scratch appended since
11766    /// the previous upload — chunks later in the frame append after regions
11767    /// whose draws are already encoded, so earlier bytes are never
11768    /// rewritten and the fixed-capacity buffers are never recreated
11769    /// mid-frame. The executor-owned upload lands at the head of the next
11770    /// submit, which is where this frame's passes execute.
11771    #[cfg(not(target_arch = "wasm32"))]
11772    fn upload_transient_rim_meshes(&mut self) {
11773        let device = self.device.clone();
11774        let mut upload_stats = crate::frame_graph::FrameCommandStats::default();
11775        if self.rim_mesh_vertices.len() > self.rim_mesh_uploaded_vertices {
11776            let vertex_buffer = self.rim_mesh_vertex_buffer.get_or_insert_with(|| {
11777                device.create_buffer(&wgpu::BufferDescriptor {
11778                    label: Some("Rim Mesh Vertex Buffer"),
11779                    size: (RIM_MESH_VERTEX_CAPACITY * std::mem::size_of::<MeshVertex>()) as u64,
11780                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
11781                    mapped_at_creation: false,
11782                })
11783            });
11784            upload_stats.upload_bytes += self
11785                .frame_graph_executor
11786                .upload_buffer(
11787                    &self.queue,
11788                    vertex_buffer,
11789                    (self.rim_mesh_uploaded_vertices * std::mem::size_of::<MeshVertex>()) as u64,
11790                    bytemuck::cast_slice(
11791                        &self.rim_mesh_vertices[self.rim_mesh_uploaded_vertices..],
11792                    ),
11793                )
11794                .upload_bytes;
11795            self.rim_mesh_uploaded_vertices = self.rim_mesh_vertices.len();
11796        }
11797        if self.rim_mesh_indices.len() > self.rim_mesh_uploaded_indices {
11798            let index_buffer = self.rim_mesh_index_buffer.get_or_insert_with(|| {
11799                device.create_buffer(&wgpu::BufferDescriptor {
11800                    label: Some("Rim Mesh Index Buffer"),
11801                    size: (RIM_MESH_INDEX_CAPACITY * std::mem::size_of::<u32>()) as u64,
11802                    usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
11803                    mapped_at_creation: false,
11804                })
11805            });
11806            upload_stats.upload_bytes += self
11807                .frame_graph_executor
11808                .upload_buffer(
11809                    &self.queue,
11810                    index_buffer,
11811                    (self.rim_mesh_uploaded_indices * std::mem::size_of::<u32>()) as u64,
11812                    bytemuck::cast_slice(&self.rim_mesh_indices[self.rim_mesh_uploaded_indices..]),
11813                )
11814                .upload_bytes;
11815            self.rim_mesh_uploaded_indices = self.rim_mesh_indices.len();
11816        }
11817        if upload_stats.upload_bytes > 0 {
11818            self.frame_stats.record_command_stats(upload_stats);
11819        }
11820    }
11821
11822    fn draw_prepared_shapes(
11823        &self,
11824        render_pass: &mut wgpu::RenderPass<'_>,
11825        blend_mode: BlendMode,
11826        batch: PreparedShapeBatch,
11827        width: u32,
11828        height: u32,
11829        rims: &[RimDraw],
11830    ) {
11831        #[cfg(target_arch = "wasm32")]
11832        let _ = rims;
11833        self.frame_stats.bump_shapes();
11834        self.frame_stats.add_draw_calls(1);
11835        render_pass.set_scissor_rect(0, 0, width, height);
11836        #[cfg(not(target_arch = "wasm32"))]
11837        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
11838        #[cfg(target_arch = "wasm32")]
11839        let (uniform_bind_group, shape_buffers) = (
11840            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
11841            &self.wasm_shape_batches[batch.shape_slot],
11842        );
11843        // Latched instanced path (storage mode only): one instance per
11844        // shape, four vertices through the static quad index buffer —
11845        // identical triangles, identical bind groups, still one draw call.
11846        // The uniform/WebGL path never latches it and stays on `vs_main`.
11847        #[cfg(not(target_arch = "wasm32"))]
11848        if let Some(instanced) = &self.instanced_quads {
11849            assert!(
11850                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
11851                "shape batches are whole shapes: vertex range {}..+{} must be \
11852                 six-aligned to convert to an instance range",
11853                batch.vertex_start,
11854                batch.vertex_count,
11855            );
11856            // The same selection the preamble and every post-rim restore
11857            // make — factored so the two sites cannot disagree.
11858            let set_instanced_pipeline = |render_pass: &mut wgpu::RenderPass<'_>| {
11859                if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
11860                    render_pass.set_pipeline(self.instanced_pipeline_solid(instanced));
11861                } else {
11862                    render_pass.set_pipeline(self.instanced_pipeline(instanced, blend_mode));
11863                }
11864            };
11865            set_instanced_pipeline(render_pass);
11866            render_pass.set_bind_group(0, uniform_bind_group, &[]);
11867            // Dynamic offset 0: ordinary batches read the identity
11868            // similarity transform.
11869            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
11870            let first_shape = batch.vertex_start / 6;
11871            let shape_count = batch.vertex_count / 6;
11872            render_pass
11873                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
11874            // Rims arrive in ascending shape order (step 4 walks the fused
11875            // upload front to back), so this batch's rims are one contiguous
11876            // run of the slice.
11877            debug_assert!(
11878                rims.windows(2)
11879                    .all(|pair| pair[0].shape_index < pair[1].shape_index),
11880                "rim draws must arrive in ascending shape order"
11881            );
11882            let rim_start = rims.partition_point(|rim| rim.shape_index < first_shape);
11883            let rim_end = rims.partition_point(|rim| rim.shape_index < first_shape + shape_count);
11884            let batch_rims = &rims[rim_start..rim_end];
11885            let rim_buffers = match (&self.rim_mesh_vertex_buffer, &self.rim_mesh_index_buffer) {
11886                (Some(vertex_buffer), Some(index_buffer)) if !batch_rims.is_empty() => {
11887                    Some((vertex_buffer, index_buffer))
11888                }
11889                _ => None,
11890            };
11891            let Some((rim_vertex_buffer, rim_index_buffer)) = rim_buffers else {
11892                render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
11893                return;
11894            };
11895            // Split the instance range around each rim, in exact shape
11896            // order, so z is untouched: instances before the rim, the rim's
11897            // band mesh through `vs_mesh`, instances after. Bind groups
11898            // persist across `set_pipeline` because the mesh and instanced
11899            // pipelines share identical bind group layouts (uniform layout +
11900            // shape layout, dynamic similarity offset included), so only the
11901            // pipeline and index/vertex buffers are re-set per switch.
11902            let mut draw_calls = 0u32;
11903            let mut cursor = first_shape;
11904            for rim in batch_rims {
11905                if cursor < rim.shape_index {
11906                    render_pass.draw_indexed(0..6, 0, cursor..rim.shape_index);
11907                    draw_calls += 1;
11908                }
11909                render_pass.set_pipeline(self.mesh_pipeline());
11910                render_pass.set_vertex_buffer(0, rim_vertex_buffer.slice(..));
11911                render_pass.set_index_buffer(rim_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
11912                render_pass.draw_indexed(
11913                    rim.first_index..rim.first_index + rim.index_count,
11914                    0,
11915                    0..1,
11916                );
11917                draw_calls += 1;
11918                set_instanced_pipeline(render_pass);
11919                render_pass
11920                    .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
11921                cursor = rim.shape_index + 1;
11922            }
11923            if cursor < first_shape + shape_count {
11924                render_pass.draw_indexed(0..6, 0, cursor..first_shape + shape_count);
11925                draw_calls += 1;
11926            }
11927            // One draw call was already counted at the top of the fn.
11928            self.frame_stats
11929                .add_draw_calls(draw_calls.saturating_sub(1));
11930            return;
11931        }
11932        if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
11933            render_pass.set_pipeline(self.shape_pipeline_solid());
11934        } else {
11935            render_pass.set_pipeline(self.shape_pipeline(blend_mode));
11936        }
11937        render_pass.set_bind_group(0, uniform_bind_group, &[]);
11938        // Dynamic offset 0: ordinary batches read the identity similarity
11939        // transform.
11940        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
11941        // Six unindexed vertices per shape; `vs_main` derives the corner from
11942        // `vertex_index` and pulls the quad out of `ShapeData`.
11943        render_pass.draw(
11944            batch.vertex_start..batch.vertex_start + batch.vertex_count,
11945            0..1,
11946        );
11947    }
11948
11949    /// Stage shape buffer writes and record a shape render pass onto the
11950    /// provided encoder. The caller is responsible for submitting.
11951    #[allow(clippy::too_many_arguments)]
11952    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
11953        &mut self,
11954        frame_encoder: &mut C,
11955        target_view: &wgpu::TextureView,
11956        layer_shapes: I,
11957        brushes: &[Brush],
11958        blend_mode: BlendMode,
11959        width: u32,
11960        height: u32,
11961        root_scale: f32,
11962        load_op: wgpu::LoadOp<wgpu::Color>,
11963        viewport_offset: [f32; 2],
11964    ) where
11965        I: Iterator<Item = &'a DrawShape>,
11966    {
11967        let mut staged_uploads = self.take_staged_uploads();
11968        let viewport = ViewportUniformParams {
11969            width,
11970            height,
11971            offset: viewport_offset,
11972        };
11973        let Some(batch) = self.prepare_shapes_batch(
11974            layer_shapes
11975                .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
11976            brushes,
11977            root_scale,
11978            viewport,
11979            &mut staged_uploads,
11980        ) else {
11981            self.restore_staged_uploads(staged_uploads);
11982            return;
11983        };
11984        let upload_offset =
11985            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
11986        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
11987        self.restore_staged_uploads(staged_uploads);
11988        let mut render_pass =
11989            frame_encoder
11990                .encoder()
11991                .begin_render_pass(&wgpu::RenderPassDescriptor {
11992                    label: Some("Shape Pass"),
11993                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11994                        view: target_view,
11995                        resolve_target: None,
11996                        depth_slice: None,
11997                        ops: wgpu::Operations {
11998                            load: load_op,
11999                            store: wgpu::StoreOp::Store,
12000                        },
12001                    })],
12002                    depth_stencil_attachment: None,
12003                    timestamp_writes: None,
12004                    occlusion_query_set: None,
12005                    multiview_mask: None,
12006                });
12007        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height, &[]);
12008    }
12009
12010    fn draw_prepared_images(
12011        &mut self,
12012        render_pass: &mut wgpu::RenderPass<'_>,
12013        batch: &PreparedImageBatch,
12014        blend_mode: BlendMode,
12015    ) -> Result<(), String> {
12016        if batch.cmds.is_empty() {
12017            return Ok(());
12018        }
12019        self.frame_stats.bump_images();
12020        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
12021        render_pass.set_pipeline(self.image_pipeline(blend_mode));
12022        #[cfg(not(target_arch = "wasm32"))]
12023        let (uniform_bind_group, vertex_buffer, index_buffer) = (
12024            &self.uniform_bind_group,
12025            &self.image_vertex_buffer,
12026            &self.image_index_buffer,
12027        );
12028        #[cfg(target_arch = "wasm32")]
12029        let (uniform_bind_group, vertex_buffer, index_buffer) = (
12030            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
12031            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
12032            &self.wasm_image_batches[batch.image_slot].index_buffer,
12033        );
12034        render_pass.set_bind_group(0, uniform_bind_group, &[]);
12035        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
12036        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
12037
12038        for cmd in &batch.cmds {
12039            let (sx, sy, sw, sh) = cmd.scissor;
12040            render_pass.set_scissor_rect(sx, sy, sw, sh);
12041
12042            let cached = self
12043                .image_texture_cache
12044                .get(&cmd.image_id)
12045                .ok_or_else(|| "image texture missing from cache".to_string())?;
12046            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
12047            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
12048        }
12049        Ok(())
12050    }
12051
12052    fn draw_prepared_glyphs(
12053        &mut self,
12054        render_pass: &mut wgpu::RenderPass<'_>,
12055        batch: &PreparedGlyphBatch,
12056    ) -> Result<(), String> {
12057        if batch.cmds.is_empty() {
12058            return Ok(());
12059        }
12060        #[cfg(not(target_arch = "wasm32"))]
12061        {
12062            self.draw_native_prepared_glyph_cmd_range(
12063                render_pass,
12064                &batch.cmds,
12065                0..batch.cmds.len(),
12066            )?;
12067        }
12068        #[cfg(target_arch = "wasm32")]
12069        {
12070            self.frame_stats.bump_text();
12071            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
12072            render_pass.set_pipeline(self.glyph_atlas_pipeline());
12073            let (uniform_bind_group, vertex_buffer, index_buffer) = (
12074                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
12075                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
12076                &self.wasm_image_batches[batch.image_slot].index_buffer,
12077            );
12078            render_pass.set_bind_group(0, uniform_bind_group, &[]);
12079            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
12080            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
12081            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
12082
12083            for cmd in &batch.cmds {
12084                let (sx, sy, sw, sh) = cmd.scissor;
12085                render_pass.set_scissor_rect(sx, sy, sw, sh);
12086                let GlyphDrawSource::Shared {
12087                    index_start,
12088                    index_count,
12089                } = cmd.source;
12090                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
12091            }
12092        }
12093        Ok(())
12094    }
12095
12096    #[cfg(not(target_arch = "wasm32"))]
12097    fn draw_native_prepared_image_cmd_range(
12098        &mut self,
12099        render_pass: &mut wgpu::RenderPass<'_>,
12100        cmds: &[ImageDrawCmd],
12101        cmd_range: Range<usize>,
12102        blend_mode: BlendMode,
12103    ) -> Result<(), String> {
12104        let Some(cmds) = cmds.get(cmd_range) else {
12105            return Err("image command range is outside the prepared command buffer".to_string());
12106        };
12107        if cmds.is_empty() {
12108            return Ok(());
12109        }
12110
12111        self.frame_stats.bump_images();
12112        self.frame_stats.add_draw_calls(cmds.len() as u32);
12113        render_pass.set_pipeline(self.image_pipeline(blend_mode));
12114        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
12115        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
12116        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
12117
12118        for cmd in cmds {
12119            let (sx, sy, sw, sh) = cmd.scissor;
12120            render_pass.set_scissor_rect(sx, sy, sw, sh);
12121
12122            let cached = self
12123                .image_texture_cache
12124                .get(&cmd.image_id)
12125                .ok_or_else(|| "image texture missing from cache".to_string())?;
12126            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
12127            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
12128        }
12129        Ok(())
12130    }
12131
12132    #[cfg(not(target_arch = "wasm32"))]
12133    fn draw_native_prepared_glyph_cmd_range(
12134        &mut self,
12135        render_pass: &mut wgpu::RenderPass<'_>,
12136        cmds: &[GlyphDrawCmd],
12137        cmd_range: Range<usize>,
12138    ) -> Result<(), String> {
12139        let Some(cmds) = cmds.get(cmd_range) else {
12140            return Err("glyph command range is outside the prepared command buffer".to_string());
12141        };
12142        if cmds.is_empty() {
12143            return Ok(());
12144        }
12145
12146        self.frame_stats.bump_text();
12147        self.frame_stats.add_draw_calls(cmds.len() as u32);
12148
12149        let mut shared_buffers_bound = false;
12150        let mut retained_pipeline_bound = false;
12151        for cmd in cmds {
12152            let (sx, sy, sw, sh) = cmd.scissor;
12153            render_pass.set_scissor_rect(sx, sy, sw, sh);
12154            match cmd.source {
12155                GlyphDrawSource::Shared {
12156                    index_start,
12157                    index_count,
12158                } => {
12159                    if retained_pipeline_bound || !shared_buffers_bound {
12160                        render_pass.set_pipeline(self.glyph_atlas_pipeline());
12161                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
12162                        retained_pipeline_bound = false;
12163                    }
12164                    if !shared_buffers_bound {
12165                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
12166                        render_pass.set_index_buffer(
12167                            self.image_index_buffer.slice(..),
12168                            wgpu::IndexFormat::Uint32,
12169                        );
12170                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
12171                        shared_buffers_bound = true;
12172                    }
12173                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
12174                }
12175                GlyphDrawSource::Retained {
12176                    cache_key,
12177                    uniform_slot,
12178                } => {
12179                    shared_buffers_bound = false;
12180                    if !retained_pipeline_bound {
12181                        render_pass.set_pipeline(self.retained_glyph_atlas_pipeline());
12182                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
12183                        retained_pipeline_bound = true;
12184                    }
12185                    let cached = self
12186                        .text_glyph_gpu_run_cache
12187                        .peek(&cache_key)
12188                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
12189                    let dynamic_offset =
12190                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
12191                    render_pass.set_bind_group(
12192                        0,
12193                        &self.retained_glyph_uniform_bind_group,
12194                        &[dynamic_offset],
12195                    );
12196                    render_pass
12197                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
12198                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
12199                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
12200                }
12201            }
12202        }
12203        Ok(())
12204    }
12205
12206    fn append_image_draw_cmd(
12207        &mut self,
12208        image_draw: &ImageDraw,
12209        viewport: ViewportUniformParams,
12210        root_scale: f32,
12211        image_vertices: &mut Vec<Vertex>,
12212        image_indices: &mut Vec<u32>,
12213        image_cmds: &mut Vec<ImageDrawCmd>,
12214    ) -> Result<(), String> {
12215        let snap_delta = image_draw
12216            .snap_anchor
12217            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
12218            .unwrap_or_default();
12219        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
12220        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
12221            return Ok(());
12222        }
12223
12224        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
12225        if tint[3] <= 0.0 {
12226            return Ok(());
12227        }
12228
12229        let prepared_image = if let Some(filter) = cpu_filter {
12230            apply_filter_to_bitmap(&image_draw.image, filter)?
12231        } else {
12232            image_draw.image.clone()
12233        };
12234        self.ensure_image_cached(&prepared_image)?;
12235
12236        let mut adjusted_image = ImageDraw {
12237            rect,
12238            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
12239            quad: translate_quad(image_draw.quad, snap_delta),
12240            snap_anchor: image_draw.snap_anchor,
12241            image: image_draw.image.clone(),
12242            alpha: image_draw.alpha,
12243            color_filter: image_draw.color_filter,
12244            sampling: image_draw.sampling,
12245            z_index: image_draw.z_index,
12246            clip: image_draw.clip,
12247            blend_mode: image_draw.blend_mode,
12248            src_rect: image_draw.src_rect,
12249            motion_context_animated: image_draw.motion_context_animated,
12250        };
12251        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
12252        let Some(scissor) =
12253            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
12254        else {
12255            return Ok(());
12256        };
12257
12258        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
12259            return Ok(());
12260        };
12261        let device_quad =
12262            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
12263                if adjusted_image.snap_anchor.is_some() {
12264                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
12265                } else {
12266                    scaled_quad(adjusted_image.quad, root_scale)
12267                }
12268            });
12269        #[cfg(not(target_arch = "wasm32"))]
12270        {
12271            if fill_area_diag_enabled() {
12272                self.fill_area_diag.add_image_quad(&device_quad);
12273            }
12274        }
12275
12276        let base_vertex = image_vertices.len() as u32;
12277        let index_start = image_indices.len() as u32;
12278        image_indices.extend_from_slice(&[
12279            base_vertex,
12280            base_vertex + 1,
12281            base_vertex + 2,
12282            base_vertex + 2,
12283            base_vertex + 1,
12284            base_vertex + 3,
12285        ]);
12286        image_vertices.extend_from_slice(&[
12287            Vertex {
12288                position: device_quad[0],
12289                color: tint,
12290                uv: [uv_rect.min[0], uv_rect.min[1]],
12291                uv_bounds: uv_rect.sample_bounds,
12292            },
12293            Vertex {
12294                position: device_quad[1],
12295                color: tint,
12296                uv: [uv_rect.max[0], uv_rect.min[1]],
12297                uv_bounds: uv_rect.sample_bounds,
12298            },
12299            Vertex {
12300                position: device_quad[2],
12301                color: tint,
12302                uv: [uv_rect.min[0], uv_rect.max[1]],
12303                uv_bounds: uv_rect.sample_bounds,
12304            },
12305            Vertex {
12306                position: device_quad[3],
12307                color: tint,
12308                uv: [uv_rect.max[0], uv_rect.max[1]],
12309                uv_bounds: uv_rect.sample_bounds,
12310            },
12311        ]);
12312
12313        image_cmds.push(ImageDrawCmd {
12314            index_start,
12315            scissor,
12316            image_id: prepared_image.id(),
12317            sampling: image_draw.sampling,
12318        });
12319        Ok(())
12320    }
12321
12322    #[cfg(not(target_arch = "wasm32"))]
12323    fn stage_native_image_buffers(
12324        &mut self,
12325        staged_uploads: &mut StagedBufferUploads,
12326        viewport: ViewportUniformParams,
12327        image_vertices: &[Vertex],
12328        image_indices: &[u32],
12329    ) {
12330        if image_indices.is_empty() {
12331            return;
12332        }
12333
12334        self.stage_viewport_uniforms(staged_uploads, viewport);
12335        // Grow to a power of two, as the shape batch and frame upload buffers
12336        // do. Sizing these to the exact byte count instead means one more glyph
12337        // quad than the last frame destroys and recreates both buffers, and a
12338        // caption that grows a character at a time does it on every frame.
12339        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
12340        if needed_bytes > self.image_vertex_buffer.size() {
12341            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12342                label: Some("Image Vertex Buffer"),
12343                size: needed_bytes.next_power_of_two(),
12344                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
12345                mapped_at_creation: false,
12346            });
12347        }
12348        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
12349        if needed_index_bytes > self.image_index_buffer.size() {
12350            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12351                label: Some("Image Index Buffer"),
12352                size: needed_index_bytes.next_power_of_two(),
12353                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
12354                mapped_at_creation: false,
12355            });
12356        }
12357
12358        staged_uploads.stage(
12359            UploadTarget::ImageVertex,
12360            bytemuck::cast_slice(image_vertices),
12361        );
12362        staged_uploads.stage(
12363            UploadTarget::ImageIndex,
12364            bytemuck::cast_slice(image_indices),
12365        );
12366    }
12367
12368    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
12369    /// Returns the draw commands needed by `encode_images_pass`.
12370    fn prepare_image_draw_cmds<'a, I>(
12371        &mut self,
12372        layer_images: I,
12373        viewport: ViewportUniformParams,
12374        root_scale: f32,
12375        staged_uploads: &mut StagedBufferUploads,
12376    ) -> Result<PreparedImageBatch, String>
12377    where
12378        I: Iterator<Item = &'a ImageDraw>,
12379    {
12380        #[cfg(target_arch = "wasm32")]
12381        let _ = staged_uploads;
12382
12383        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
12384        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
12385        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
12386        image_vertices.clear();
12387        image_indices.clear();
12388        image_cmds.clear();
12389
12390        for image_draw in layer_images {
12391            self.append_image_draw_cmd(
12392                image_draw,
12393                viewport,
12394                root_scale,
12395                &mut image_vertices,
12396                &mut image_indices,
12397                &mut image_cmds,
12398            )?;
12399        }
12400
12401        #[cfg(not(target_arch = "wasm32"))]
12402        if !image_cmds.is_empty() {
12403            self.stage_native_image_buffers(
12404                staged_uploads,
12405                viewport,
12406                &image_vertices,
12407                &image_indices,
12408            );
12409        }
12410
12411        #[cfg(target_arch = "wasm32")]
12412        let image_slot = if image_cmds.is_empty() {
12413            0
12414        } else {
12415            let slot = self.claim_wasm_image_batch();
12416            {
12417                let buffers = &mut self.wasm_image_batches[slot];
12418                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
12419            }
12420            let buffers = &self.wasm_image_batches[slot];
12421            self.write_wasm_buffer(
12422                &buffers.vertex_buffer,
12423                bytemuck::cast_slice(&image_vertices),
12424            );
12425            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
12426            slot
12427        };
12428
12429        #[cfg(target_arch = "wasm32")]
12430        let uniform_slot = if image_cmds.is_empty() {
12431            0
12432        } else {
12433            self.prepare_wasm_viewport_uniforms(viewport)
12434        };
12435
12436        self.scratch_image_vertices = image_vertices;
12437        self.scratch_image_indices = image_indices;
12438        Ok(PreparedImageBatch {
12439            cmds: image_cmds,
12440            #[cfg(target_arch = "wasm32")]
12441            image_slot,
12442            #[cfg(target_arch = "wasm32")]
12443            uniform_slot,
12444        })
12445    }
12446
12447    fn glyph_atlas_entry_for(
12448        &mut self,
12449        glyph: &SoftwareGlyphAtlasGlyph,
12450    ) -> Result<GlyphAtlasEntry, String> {
12451        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
12452            glyph.key,
12453            glyph,
12454            &self.queue,
12455            &mut self.frame_graph_executor,
12456            &mut self.frame_stats,
12457        ) {
12458            return Ok(entry);
12459        }
12460
12461        self.text_glyph_atlas.reset(
12462            &self.device,
12463            &self.image_bind_group_layout,
12464            &self.image_nearest_sampler,
12465        );
12466        Err("text glyph atlas filled and was reset".to_string())
12467    }
12468
12469    fn glyph_atlas_entry_for_cached(
12470        &mut self,
12471        glyph: &SoftwareGlyphAtlasPlacement,
12472    ) -> Option<GlyphAtlasEntry> {
12473        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
12474        self.frame_stats.record_text_glyph_atlas_hit();
12475        Some(entry)
12476    }
12477
12478    fn glyph_atlas_entry_for_placement(
12479        &mut self,
12480        glyph: &SoftwareGlyphAtlasPlacement,
12481    ) -> Result<GlyphAtlasEntry, String> {
12482        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
12483            return Ok(entry);
12484        }
12485
12486        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
12487            return Err("text glyph placement has no retained raster mask".to_string());
12488        };
12489        self.glyph_atlas_entry_for(&upload_glyph)
12490    }
12491
12492    fn prepare_text_glyph_quads(
12493        &mut self,
12494        run_key: TextGlyphRunCacheKey,
12495        atlas_generation: u64,
12496        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
12497        collected_run: &[SoftwareGlyphAtlasRunGlyph],
12498        generated_quads: &mut Vec<CachedTextGlyphQuad>,
12499    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
12500        generated_quads.clear();
12501        if let Some(glyph_run) = cached_glyph_run {
12502            for glyph in glyph_run {
12503                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
12504                    continue;
12505                }
12506                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
12507                // Read the size after the entry is in hand: the only path that
12508                // resizes the atlas is the overflow reset, which returns `Err`
12509                // above, so `entry` is always normalised against the atlas it
12510                // was placed in.
12511                generated_quads.push(cached_text_glyph_quad(
12512                    glyph,
12513                    entry,
12514                    self.text_glyph_atlas.size(),
12515                ));
12516            }
12517        } else {
12518            for run_glyph in collected_run {
12519                let placement = run_glyph.placement();
12520                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
12521                    continue;
12522                }
12523                let entry = match run_glyph {
12524                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
12525                        self.glyph_atlas_entry_for_placement(placement)?
12526                    }
12527                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
12528                };
12529                generated_quads.push(cached_text_glyph_quad(
12530                    &placement,
12531                    entry,
12532                    self.text_glyph_atlas.size(),
12533                ));
12534            }
12535        }
12536
12537        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
12538        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
12539            cached.quads = Some(Rc::clone(&quads));
12540            cached.atlas_generation = atlas_generation;
12541        }
12542        Ok(quads)
12543    }
12544
12545    #[allow(clippy::too_many_arguments)]
12546    fn append_text_glyph_quad_run(
12547        &mut self,
12548        source_raster_rect: Rect,
12549        quads: &[CachedTextGlyphQuad],
12550        clip: Option<Rect>,
12551        viewport: ViewportUniformParams,
12552        root_scale: f32,
12553        image_vertices: &mut Vec<Vertex>,
12554        image_indices: &mut Vec<u32>,
12555        record_cached_hits: bool,
12556    ) -> usize {
12557        let mut appended = 0usize;
12558        for quad in quads {
12559            if !cached_text_glyph_quad_is_visible_in_viewport(
12560                source_raster_rect,
12561                quad,
12562                clip,
12563                viewport,
12564                root_scale,
12565            ) {
12566                continue;
12567            }
12568            if append_cached_text_glyph_quad(
12569                source_raster_rect,
12570                quad,
12571                image_vertices,
12572                image_indices,
12573            ) {
12574                if record_cached_hits {
12575                    self.frame_stats.record_text_glyph_atlas_hit();
12576                }
12577                #[cfg(not(target_arch = "wasm32"))]
12578                {
12579                    if fill_area_diag_enabled() {
12580                        self.fill_area_diag.add_glyph_quad(quad);
12581                    }
12582                }
12583                appended = appended.saturating_add(1);
12584            }
12585        }
12586        appended
12587    }
12588
12589    #[cfg(not(target_arch = "wasm32"))]
12590    fn retained_glyph_viewport(
12591        viewport: ViewportUniformParams,
12592        source_raster_rect: Rect,
12593    ) -> ViewportUniformParams {
12594        ViewportUniformParams {
12595            width: viewport.width,
12596            height: viewport.height,
12597            offset: [
12598                viewport.offset[0] - source_raster_rect.x,
12599                viewport.offset[1] - source_raster_rect.y,
12600            ],
12601        }
12602    }
12603
12604    #[cfg(not(target_arch = "wasm32"))]
12605    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
12606        let atlas_generation = self.text_glyph_atlas.generation();
12607        self.text_glyph_gpu_run_cache
12608            .peek(&cache_key)
12609            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
12610    }
12611
12612    #[cfg(not(target_arch = "wasm32"))]
12613    #[allow(clippy::too_many_arguments)]
12614    fn emit_retained_text_glyph_run_if_ready(
12615        &mut self,
12616        cache_key: TextGlyphRunCacheKey,
12617        quads: &[CachedTextGlyphQuad],
12618        clip: Option<Rect>,
12619        viewport: ViewportUniformParams,
12620        source_raster_rect: Rect,
12621        scissor: (u32, u32, u32, u32),
12622        staged_uploads: &mut StagedBufferUploads,
12623        glyph_cmds: &mut Vec<GlyphDrawCmd>,
12624    ) -> bool {
12625        if !should_use_retained_text_glyph_run(quads.len(), clip) {
12626            return false;
12627        }
12628        if !self.retained_text_glyph_run_ready(cache_key)
12629            && !self.ensure_retained_text_glyph_run(cache_key, quads)
12630        {
12631            return false;
12632        }
12633
12634        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
12635            staged_uploads,
12636            Self::retained_glyph_viewport(viewport, source_raster_rect),
12637        );
12638        if fill_area_diag_enabled() {
12639            // The retained run draws every quad of its cached buffer; the
12640            // shared path's per-quad viewport cull is not re-run for it.
12641            for quad in quads {
12642                self.fill_area_diag.add_glyph_quad(quad);
12643            }
12644        }
12645        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
12646        true
12647    }
12648
12649    #[cfg(not(target_arch = "wasm32"))]
12650    fn ensure_retained_text_glyph_run(
12651        &mut self,
12652        cache_key: TextGlyphRunCacheKey,
12653        quads: &[CachedTextGlyphQuad],
12654    ) -> bool {
12655        let atlas_generation = self.text_glyph_atlas.generation();
12656        if self
12657            .text_glyph_gpu_run_cache
12658            .peek(&cache_key)
12659            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
12660        {
12661            return true;
12662        }
12663
12664        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
12665        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
12666        let origin = Rect {
12667            x: 0.0,
12668            y: 0.0,
12669            width: 0.0,
12670            height: 0.0,
12671        };
12672        for quad in quads {
12673            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
12674        }
12675        if indices.is_empty() {
12676            return false;
12677        }
12678
12679        let vertex_bytes = bytemuck::cast_slice(&vertices);
12680        let index_bytes = bytemuck::cast_slice(&indices);
12681        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12682            label: Some("Retained Text Glyph Vertex Buffer"),
12683            size: vertex_bytes.len() as u64,
12684            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
12685            mapped_at_creation: false,
12686        });
12687        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
12688            label: Some("Retained Text Glyph Index Buffer"),
12689            size: index_bytes.len() as u64,
12690            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
12691            mapped_at_creation: false,
12692        });
12693        let vertex_upload =
12694            self.frame_graph_executor
12695                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
12696        self.frame_stats.record_command_stats(vertex_upload);
12697        let index_upload =
12698            self.frame_graph_executor
12699                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
12700        self.frame_stats.record_command_stats(index_upload);
12701
12702        self.text_glyph_gpu_run_cache.put(
12703            cache_key,
12704            CachedGpuTextGlyphRun {
12705                vertex_buffer,
12706                index_buffer,
12707                index_count: indices.len() as u32,
12708                atlas_generation,
12709            },
12710        );
12711        true
12712    }
12713
12714    #[allow(clippy::too_many_arguments)]
12715    fn append_text_glyph_draws<'a, I>(
12716        &mut self,
12717        layer_texts: I,
12718        viewport: ViewportUniformParams,
12719        root_scale: f32,
12720        allow_offscreen_prewarm: bool,
12721        staged_uploads: &mut StagedBufferUploads,
12722        image_vertices: &mut Vec<Vertex>,
12723        image_indices: &mut Vec<u32>,
12724        glyph_cmds: &mut Vec<GlyphDrawCmd>,
12725    ) -> Result<bool, String>
12726    where
12727        I: IntoIterator<Item = &'a TextDraw>,
12728    {
12729        let append_start = Instant::now();
12730        let initial_vertex_len = image_vertices.len();
12731        let initial_index_len = image_indices.len();
12732        let initial_cmd_len = glyph_cmds.len();
12733        let initial_staged_bytes_len = staged_uploads.bytes.len();
12734        let initial_staged_copies_len = staged_uploads.copies.len();
12735        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
12736        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
12737        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
12738        generated_quads.clear();
12739        let mut visited = 0usize;
12740        let mut emitted_glyphs = 0usize;
12741        let mut prewarmed_glyphs = 0usize;
12742        let mut run_hits = 0usize;
12743        let mut run_misses = 0usize;
12744
12745        for text_draw in layer_texts {
12746            visited = visited.saturating_add(1);
12747            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
12748                self.text_raster_geometry(text_draw, root_scale)
12749            else {
12750                continue;
12751            };
12752            if !static_text_motion {
12753                image_vertices.truncate(initial_vertex_len);
12754                image_indices.truncate(initial_index_len);
12755                glyph_cmds.truncate(initial_cmd_len);
12756                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
12757                self.scratch_text_glyph_run = collected_run;
12758                self.scratch_text_glyph_placements = collected_placements;
12759                self.scratch_text_glyph_quads = generated_quads;
12760                return Ok(false);
12761            }
12762            let is_visible =
12763                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
12764            let draw_action = text_glyph_draw_action(
12765                is_visible,
12766                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
12767                allow_offscreen_prewarm,
12768            );
12769            if draw_action == TextGlyphDrawAction::Skip {
12770                continue;
12771            }
12772
12773            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
12774            let source_draw = raster_source.draw.as_ref();
12775            let source_raster_rect = raster_source.raster_rect;
12776
12777            let run_key = Self::text_glyph_run_cache_key(
12778                source_draw,
12779                source_raster_rect,
12780                text_scale,
12781                static_text_motion,
12782            );
12783            let atlas_generation = self.text_glyph_atlas.generation();
12784            let mut cached_quad_run = None;
12785            let mut miss_collect_ms = None;
12786            let mut miss_cached_glyphs = 0usize;
12787            let mut miss_new_glyphs = 0usize;
12788            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
12789                run_hits = run_hits.saturating_add(1);
12790                if cached.atlas_generation == atlas_generation {
12791                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
12792                }
12793                Some(Rc::clone(&cached.glyphs))
12794            } else {
12795                run_misses = run_misses.saturating_add(1);
12796                collected_run.clear();
12797                let collect_start = Instant::now();
12798                let collect_result = collect_solid_text_atlas_run(
12799                    source_draw.text.as_ref(),
12800                    source_raster_rect,
12801                    &source_draw.text_style,
12802                    source_draw.color,
12803                    source_draw.font_size,
12804                    text_scale,
12805                    &self.text_fonts,
12806                    &mut self.text_glyph_mask_cache,
12807                    &mut collected_run,
12808                );
12809                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
12810                if collect_result.is_none() {
12811                    if text_atlas_fallback_diag_enabled() {
12812                        let preview: String = source_draw.text.text.chars().take(96).collect();
12813                        log::warn!(
12814                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
12815                            source_draw.node_id,
12816                            is_visible,
12817                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
12818                            source_draw.text.span_styles.len(),
12819                            source_draw.text.links.len(),
12820                            source_draw.text.text.len(),
12821                            preview,
12822                            source_draw.text_style.span_style,
12823                            source_draw.text_style.paragraph_style,
12824                        );
12825                    }
12826                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
12827                        continue;
12828                    }
12829                    image_vertices.truncate(initial_vertex_len);
12830                    image_indices.truncate(initial_index_len);
12831                    glyph_cmds.truncate(initial_cmd_len);
12832                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
12833                    self.scratch_text_glyph_run = collected_run;
12834                    self.scratch_text_glyph_placements = collected_placements;
12835                    self.scratch_text_glyph_quads = generated_quads;
12836                    return Ok(false);
12837                }
12838                if text_glyph_run_diag_enabled() {
12839                    miss_cached_glyphs = collected_run
12840                        .iter()
12841                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
12842                        .count();
12843                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
12844                }
12845                collected_placements.clear();
12846                collected_placements.extend(
12847                    collected_run
12848                        .iter()
12849                        .map(SoftwareGlyphAtlasRunGlyph::placement),
12850                );
12851                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
12852                    Rc::from(collected_placements.clone().into_boxed_slice());
12853                self.text_glyph_run_cache.put(
12854                    run_key,
12855                    CachedTextGlyphRun {
12856                        glyphs,
12857                        quads: None,
12858                        atlas_generation: 0,
12859                    },
12860                );
12861                None
12862            };
12863
12864            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
12865                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
12866                    quad_run
12867                } else {
12868                    let prepare_start = Instant::now();
12869                    match self.prepare_text_glyph_quads(
12870                        run_key,
12871                        atlas_generation,
12872                        cached_glyph_run.as_deref(),
12873                        &collected_run,
12874                        &mut generated_quads,
12875                    ) {
12876                        Ok(quads) => {
12877                            if let Some(collect_ms) = miss_collect_ms {
12878                                if text_glyph_run_diag_enabled() {
12879                                    log::warn!(
12880                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
12881                                        quads.len(),
12882                                        miss_cached_glyphs,
12883                                        miss_new_glyphs,
12884                                        collect_ms,
12885                                        instant_ms(prepare_start, Instant::now()),
12886                                    );
12887                                }
12888                            }
12889                            quads
12890                        }
12891                        Err(_) => continue,
12892                    }
12893                };
12894                #[cfg(not(target_arch = "wasm32"))]
12895                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
12896                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
12897                }
12898                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
12899                continue;
12900            }
12901
12902            let draw_rect = Rect {
12903                x: source_raster_rect.x / root_scale,
12904                y: source_raster_rect.y / root_scale,
12905                width: source_raster_rect.width / root_scale,
12906                height: source_raster_rect.height / root_scale,
12907            };
12908            let Some(scissor) = scissor_rect_for_layer(
12909                draw_rect,
12910                source_draw.clip,
12911                root_scale,
12912                viewport.width,
12913                viewport.height,
12914            ) else {
12915                continue;
12916            };
12917
12918            #[cfg(not(target_arch = "wasm32"))]
12919            if let Some(quad_run) = cached_quad_run.as_ref() {
12920                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
12921                    && self.emit_retained_text_glyph_run_if_ready(
12922                        run_key,
12923                        quad_run.as_ref(),
12924                        source_draw.clip,
12925                        viewport,
12926                        source_raster_rect,
12927                        scissor,
12928                        staged_uploads,
12929                        glyph_cmds,
12930                    )
12931                {
12932                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
12933                    continue;
12934                }
12935            }
12936
12937            let index_start = image_indices.len() as u32;
12938            if let Some(quad_run) = cached_quad_run {
12939                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
12940                    source_raster_rect,
12941                    quad_run.as_ref(),
12942                    source_draw.clip,
12943                    viewport,
12944                    root_scale,
12945                    image_vertices,
12946                    image_indices,
12947                    true,
12948                ));
12949            } else {
12950                let prepare_start = Instant::now();
12951                let Ok(quad_run) = self.prepare_text_glyph_quads(
12952                    run_key,
12953                    atlas_generation,
12954                    cached_glyph_run.as_deref(),
12955                    &collected_run,
12956                    &mut generated_quads,
12957                ) else {
12958                    image_vertices.truncate(initial_vertex_len);
12959                    image_indices.truncate(initial_index_len);
12960                    glyph_cmds.truncate(initial_cmd_len);
12961                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
12962                    self.scratch_text_glyph_run = collected_run;
12963                    self.scratch_text_glyph_placements = collected_placements;
12964                    self.scratch_text_glyph_quads = generated_quads;
12965                    return Ok(false);
12966                };
12967                if let Some(collect_ms) = miss_collect_ms {
12968                    if text_glyph_run_diag_enabled() {
12969                        log::warn!(
12970                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
12971                            quad_run.len(),
12972                            miss_cached_glyphs,
12973                            miss_new_glyphs,
12974                            collect_ms,
12975                            instant_ms(prepare_start, Instant::now()),
12976                        );
12977                    }
12978                }
12979                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
12980                    source_raster_rect,
12981                    quad_run.as_ref(),
12982                    source_draw.clip,
12983                    viewport,
12984                    root_scale,
12985                    image_vertices,
12986                    image_indices,
12987                    false,
12988                ));
12989            }
12990            let index_count = image_indices.len() as u32 - index_start;
12991            if index_count > 0 {
12992                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
12993            }
12994        }
12995
12996        self.scratch_text_glyph_run = collected_run;
12997        self.scratch_text_glyph_placements = collected_placements;
12998        self.scratch_text_glyph_quads = generated_quads;
12999        let append_end = Instant::now();
13000        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
13001            log::warn!(
13002                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
13003                visited,
13004                glyph_cmds.len().saturating_sub(initial_cmd_len),
13005                emitted_glyphs,
13006                prewarmed_glyphs,
13007                run_hits,
13008                run_misses,
13009            );
13010        }
13011        Ok(true)
13012    }
13013
13014    #[cfg(not(target_arch = "wasm32"))]
13015    fn text_glyph_prewarm_decision(
13016        &self,
13017        text_draw: &TextDraw,
13018        viewport: ViewportUniformParams,
13019        root_scale: f32,
13020    ) -> TextGlyphPrewarmDecision {
13021        let Some((logical_rect, _, clip, _, static_text_motion)) =
13022            self.text_raster_geometry(text_draw, root_scale)
13023        else {
13024            return TextGlyphPrewarmDecision::MissingGeometry;
13025        };
13026        if !static_text_motion {
13027            return TextGlyphPrewarmDecision::DynamicMotion;
13028        }
13029        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
13030            return TextGlyphPrewarmDecision::Visible;
13031        }
13032        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
13033            TextGlyphPrewarmDecision::Candidate
13034        } else {
13035            TextGlyphPrewarmDecision::OutsidePrewarmWindow
13036        }
13037    }
13038
13039    #[cfg(not(target_arch = "wasm32"))]
13040    #[allow(clippy::too_many_arguments)]
13041    fn prewarm_offscreen_text_glyph_draws_in_chunk(
13042        &mut self,
13043        ordered_items: &[(usize, SegmentDrawItem)],
13044        texts: &[TextDraw],
13045        chunk: &SegmentDrawChunkPlan,
13046        viewport: ViewportUniformParams,
13047        root_scale: f32,
13048        staged_uploads: &mut StagedBufferUploads,
13049        image_vertices: &mut Vec<Vertex>,
13050        image_indices: &mut Vec<u32>,
13051        glyph_cmds: &mut Vec<GlyphDrawCmd>,
13052    ) -> Result<(), String> {
13053        let prewarm_start = Instant::now();
13054        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
13055        let mut text_items = 0usize;
13056        let mut candidates = 0usize;
13057        let mut missing_geometry = 0usize;
13058        let mut dynamic_motion = 0usize;
13059        let mut visible = 0usize;
13060        let mut outside = 0usize;
13061        let mut already_prepared = 0usize;
13062        let mut admitted_candidates = 0usize;
13063        let mut skipped_unbounded = 0usize;
13064        let mut skipped_budget = 0usize;
13065        let initial_vertex_len = image_vertices.len();
13066        let initial_index_len = image_indices.len();
13067        let initial_cmd_len = glyph_cmds.len();
13068        let initial_staged_bytes_len = staged_uploads.bytes.len();
13069        let initial_staged_copies_len = staged_uploads.copies.len();
13070        'batches: for batch in chunk.iter() {
13071            let SegmentBatchPlan::Text { start, end } = batch else {
13072                continue;
13073            };
13074            for (_, item) in &ordered_items[start..end] {
13075                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
13076                {
13077                    skipped_budget = skipped_budget.saturating_add(1);
13078                    break 'batches;
13079                }
13080                let SegmentDrawItem::Text(text_index) = item else {
13081                    return Err(format!(
13082                        "text prewarm batch contains non-text draw item: {item:?}"
13083                    ));
13084                };
13085                let Some(text_draw) = texts.get(*text_index) else {
13086                    continue;
13087                };
13088                text_items = text_items.saturating_add(1);
13089                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
13090                    TextGlyphPrewarmDecision::Candidate => {}
13091                    TextGlyphPrewarmDecision::MissingGeometry => {
13092                        missing_geometry = missing_geometry.saturating_add(1);
13093                        continue;
13094                    }
13095                    TextGlyphPrewarmDecision::DynamicMotion => {
13096                        dynamic_motion = dynamic_motion.saturating_add(1);
13097                        continue;
13098                    }
13099                    TextGlyphPrewarmDecision::Visible => {
13100                        visible = visible.saturating_add(1);
13101                        continue;
13102                    }
13103                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
13104                        outside = outside.saturating_add(1);
13105                        continue;
13106                    }
13107                }
13108
13109                candidates = candidates.saturating_add(1);
13110                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
13111                    self.text_raster_geometry(text_draw, root_scale)
13112                else {
13113                    missing_geometry = missing_geometry.saturating_add(1);
13114                    continue;
13115                };
13116                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
13117                let source_draw = raster_source.draw.as_ref();
13118                let run_key = Self::text_glyph_run_cache_key(
13119                    source_draw,
13120                    raster_source.raster_rect,
13121                    text_scale,
13122                    static_text_motion,
13123                );
13124                let atlas_generation = self.text_glyph_atlas.generation();
13125                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
13126                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
13127                        already_prepared = already_prepared.saturating_add(1);
13128                        continue;
13129                    }
13130                    Some(cached.glyphs.len())
13131                } else {
13132                    None
13133                };
13134                if !offscreen_text_glyph_prewarm_work_is_bounded(
13135                    cached_glyphs,
13136                    source_draw.text.text.len(),
13137                ) {
13138                    skipped_unbounded = skipped_unbounded.saturating_add(1);
13139                    continue;
13140                }
13141                admitted_candidates = admitted_candidates.saturating_add(1);
13142                self.append_text_glyph_draws(
13143                    std::iter::once(text_draw),
13144                    viewport,
13145                    root_scale,
13146                    true,
13147                    staged_uploads,
13148                    image_vertices,
13149                    image_indices,
13150                    glyph_cmds,
13151                )?;
13152                image_vertices.truncate(initial_vertex_len);
13153                image_indices.truncate(initial_index_len);
13154                glyph_cmds.truncate(initial_cmd_len);
13155                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
13156            }
13157        }
13158
13159        if diag_enabled && text_items > 0 {
13160            log::warn!(
13161                "[text-glyph-prewarm-diag] texts={text_items} candidates={candidates} admitted={admitted_candidates} cached={already_prepared} skipped_unbounded={skipped_unbounded} skipped_budget={skipped_budget} visible={visible} outside={outside} dynamic={dynamic_motion} missing={missing_geometry}"
13162            );
13163        }
13164        if admitted_candidates > 0 {
13165            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
13166                log::warn!(
13167                    "[wgpu-render-stage:text-glyph-prewarm] total_ms={total_ms:.2} candidates={candidates} admitted={admitted_candidates} cached={already_prepared} skipped_unbounded={skipped_unbounded} skipped_budget={skipped_budget}"
13168                );
13169            }
13170        }
13171        Ok(())
13172    }
13173
13174    fn prepare_text_glyph_draw_cmds<'a, I>(
13175        &mut self,
13176        layer_texts: I,
13177        viewport: ViewportUniformParams,
13178        root_scale: f32,
13179        staged_uploads: &mut StagedBufferUploads,
13180    ) -> Result<Option<PreparedGlyphBatch>, String>
13181    where
13182        I: IntoIterator<Item = &'a TextDraw>,
13183    {
13184        #[cfg(target_arch = "wasm32")]
13185        let _ = staged_uploads;
13186
13187        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
13188        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
13189        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
13190        image_vertices.clear();
13191        image_indices.clear();
13192        glyph_cmds.clear();
13193
13194        if !self.append_text_glyph_draws(
13195            layer_texts,
13196            viewport,
13197            root_scale,
13198            false,
13199            staged_uploads,
13200            &mut image_vertices,
13201            &mut image_indices,
13202            &mut glyph_cmds,
13203        )? {
13204            self.scratch_image_vertices = image_vertices;
13205            self.scratch_image_indices = image_indices;
13206            self.scratch_glyph_cmds = glyph_cmds;
13207            return Ok(None);
13208        }
13209
13210        #[cfg(not(target_arch = "wasm32"))]
13211        if !image_indices.is_empty() {
13212            self.stage_native_image_buffers(
13213                staged_uploads,
13214                viewport,
13215                &image_vertices,
13216                &image_indices,
13217            );
13218        }
13219
13220        #[cfg(target_arch = "wasm32")]
13221        let image_slot = if glyph_cmds.is_empty() {
13222            0
13223        } else {
13224            let slot = self.claim_wasm_image_batch();
13225            {
13226                let buffers = &mut self.wasm_image_batches[slot];
13227                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
13228            }
13229            let buffers = &self.wasm_image_batches[slot];
13230            self.write_wasm_buffer(
13231                &buffers.vertex_buffer,
13232                bytemuck::cast_slice(&image_vertices),
13233            );
13234            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
13235            slot
13236        };
13237
13238        #[cfg(target_arch = "wasm32")]
13239        let uniform_slot = if glyph_cmds.is_empty() {
13240            0
13241        } else {
13242            self.prepare_wasm_viewport_uniforms(viewport)
13243        };
13244
13245        self.scratch_image_vertices = image_vertices;
13246        self.scratch_image_indices = image_indices;
13247        Ok(Some(PreparedGlyphBatch {
13248            cmds: glyph_cmds,
13249            #[cfg(target_arch = "wasm32")]
13250            image_slot,
13251            #[cfg(target_arch = "wasm32")]
13252            uniform_slot,
13253        }))
13254    }
13255
13256    #[allow(clippy::too_many_arguments)]
13257    fn append_image_bitmap_draw_cmd(
13258        &mut self,
13259        image: &ImageBitmap,
13260        rect: Rect,
13261        clip: Option<Rect>,
13262        sampling: ImageSampling,
13263        viewport: ViewportUniformParams,
13264        root_scale: f32,
13265        image_vertices: &mut Vec<Vertex>,
13266        image_indices: &mut Vec<u32>,
13267        image_cmds: &mut Vec<ImageDrawCmd>,
13268    ) -> Result<(), String> {
13269        if rect.width <= 0.0 || rect.height <= 0.0 {
13270            return Ok(());
13271        }
13272
13273        self.ensure_image_cached(image)?;
13274
13275        let (device_quad, scissor_rect) =
13276            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
13277                let left_px = (rect.x * root_scale).round();
13278                let top_px = (rect.y * root_scale).round();
13279                let width_px = (rect.width * root_scale).round().max(1.0);
13280                let height_px = (rect.height * root_scale).round().max(1.0);
13281                let snapped_rect = Rect {
13282                    x: left_px / root_scale,
13283                    y: top_px / root_scale,
13284                    width: width_px / root_scale,
13285                    height: height_px / root_scale,
13286                };
13287                let right_px = left_px + width_px;
13288                let bottom_px = top_px + height_px;
13289                (
13290                    [
13291                        [left_px, top_px],
13292                        [right_px, top_px],
13293                        [left_px, bottom_px],
13294                        [right_px, bottom_px],
13295                    ],
13296                    snapped_rect,
13297                )
13298            } else {
13299                (
13300                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
13301                    rect,
13302                )
13303            };
13304
13305        let Some(scissor) = scissor_rect_for_layer(
13306            scissor_rect,
13307            clip,
13308            root_scale,
13309            viewport.width,
13310            viewport.height,
13311        ) else {
13312            return Ok(());
13313        };
13314        let Some(uv_rect) = image_uv_rect(image, None) else {
13315            return Ok(());
13316        };
13317        #[cfg(not(target_arch = "wasm32"))]
13318        {
13319            if fill_area_diag_enabled() {
13320                self.fill_area_diag.add_image_quad(&device_quad);
13321            }
13322        }
13323
13324        let base_vertex = image_vertices.len() as u32;
13325        let index_start = image_indices.len() as u32;
13326        image_indices.extend_from_slice(&[
13327            base_vertex,
13328            base_vertex + 1,
13329            base_vertex + 2,
13330            base_vertex + 2,
13331            base_vertex + 1,
13332            base_vertex + 3,
13333        ]);
13334        let color = [1.0, 1.0, 1.0, 1.0];
13335        image_vertices.extend_from_slice(&[
13336            Vertex {
13337                position: device_quad[0],
13338                color,
13339                uv: [uv_rect.min[0], uv_rect.min[1]],
13340                uv_bounds: uv_rect.sample_bounds,
13341            },
13342            Vertex {
13343                position: device_quad[1],
13344                color,
13345                uv: [uv_rect.max[0], uv_rect.min[1]],
13346                uv_bounds: uv_rect.sample_bounds,
13347            },
13348            Vertex {
13349                position: device_quad[2],
13350                color,
13351                uv: [uv_rect.min[0], uv_rect.max[1]],
13352                uv_bounds: uv_rect.sample_bounds,
13353            },
13354            Vertex {
13355                position: device_quad[3],
13356                color,
13357                uv: [uv_rect.max[0], uv_rect.max[1]],
13358                uv_bounds: uv_rect.sample_bounds,
13359            },
13360        ]);
13361        image_cmds.push(ImageDrawCmd {
13362            index_start,
13363            scissor,
13364            image_id: image.id(),
13365            sampling,
13366        });
13367        Ok(())
13368    }
13369
13370    #[allow(clippy::too_many_arguments)]
13371    fn append_text_image_draw_cmds<'a, I>(
13372        &mut self,
13373        layer_texts: I,
13374        viewport: ViewportUniformParams,
13375        root_scale: f32,
13376        image_vertices: &mut Vec<Vertex>,
13377        image_indices: &mut Vec<u32>,
13378        image_cmds: &mut Vec<ImageDrawCmd>,
13379    ) -> Result<(), String>
13380    where
13381        I: Iterator<Item = &'a TextDraw>,
13382    {
13383        let append_start = Instant::now();
13384        let initial_len = image_cmds.len();
13385        let mut visited = 0usize;
13386        let mut hit_count = 0usize;
13387        let mut miss_count = 0usize;
13388        for text_draw in layer_texts {
13389            visited = visited.saturating_add(1);
13390            let _ = text_draw.node_id;
13391            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
13392                self.text_raster_geometry(text_draw, root_scale)
13393            else {
13394                continue;
13395            };
13396            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
13397                continue;
13398            }
13399
13400            let raster_source = self.text_image_raster_source(
13401                text_draw,
13402                logical_rect,
13403                raster_rect,
13404                clip,
13405                root_scale,
13406                static_text_motion,
13407            );
13408            let source_draw = raster_source.draw.as_ref();
13409            let source_raster_rect = raster_source.raster_rect;
13410
13411            let cache_key = Self::text_image_cache_key(
13412                source_draw,
13413                source_raster_rect,
13414                text_scale,
13415                static_text_motion,
13416            );
13417            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
13418                self.frame_stats
13419                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
13420                hit_count = hit_count.saturating_add(1);
13421                cached.image.clone()
13422            } else {
13423                let Some(image) =
13424                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
13425                else {
13426                    continue;
13427                };
13428                self.frame_stats
13429                    .record_text_image_cache_miss(image.width(), image.height());
13430                miss_count = miss_count.saturating_add(1);
13431                self.text_image_cache.put(
13432                    cache_key,
13433                    CachedTextImage {
13434                        image: image.clone(),
13435                    },
13436                );
13437                image
13438            };
13439
13440            let draw_origin = if static_text_motion {
13441                Point::new(
13442                    source_raster_rect.x / root_scale,
13443                    source_raster_rect.y / root_scale,
13444                )
13445            } else {
13446                Point::new(logical_rect.x, logical_rect.y)
13447            };
13448            let draw_rect = Rect {
13449                x: draw_origin.x,
13450                y: draw_origin.y,
13451                width: image.width() as f32 / root_scale,
13452                height: image.height() as f32 / root_scale,
13453            };
13454            self.append_image_bitmap_draw_cmd(
13455                &image,
13456                draw_rect,
13457                clip,
13458                ImageSampling::Nearest,
13459                viewport,
13460                root_scale,
13461                image_vertices,
13462                image_indices,
13463                image_cmds,
13464            )?;
13465        }
13466        let append_end = Instant::now();
13467        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
13468            log::warn!(
13469                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
13470                visited,
13471                image_cmds.len().saturating_sub(initial_len),
13472                hit_count,
13473                miss_count,
13474            );
13475        }
13476        Ok(())
13477    }
13478
13479    fn text_image_raster_source<'a>(
13480        &mut self,
13481        text_draw: &'a TextDraw,
13482        logical_rect: Rect,
13483        raster_rect: Rect,
13484        clip: Option<Rect>,
13485        root_scale: f32,
13486        static_text_motion: bool,
13487    ) -> TextRasterSource<'a> {
13488        let Some(clip) = clip else {
13489            return TextRasterSource {
13490                draw: Cow::Borrowed(text_draw),
13491                raster_rect,
13492            };
13493        };
13494        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
13495            return TextRasterSource {
13496                draw: Cow::Borrowed(text_draw),
13497                raster_rect,
13498            };
13499        }
13500
13501        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
13502        clipped_text_raster_source_with_line_starts(
13503            text_draw,
13504            logical_rect,
13505            raster_rect,
13506            clip,
13507            root_scale,
13508            line_starts.as_ref(),
13509        )
13510    }
13511
13512    fn prepare_text_image_draw_cmds<'a, I>(
13513        &mut self,
13514        layer_texts: I,
13515        viewport: ViewportUniformParams,
13516        root_scale: f32,
13517        staged_uploads: &mut StagedBufferUploads,
13518    ) -> Result<PreparedImageBatch, String>
13519    where
13520        I: Iterator<Item = &'a TextDraw>,
13521    {
13522        #[cfg(target_arch = "wasm32")]
13523        let _ = staged_uploads;
13524
13525        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
13526        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
13527        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
13528        image_vertices.clear();
13529        image_indices.clear();
13530        image_cmds.clear();
13531
13532        self.append_text_image_draw_cmds(
13533            layer_texts,
13534            viewport,
13535            root_scale,
13536            &mut image_vertices,
13537            &mut image_indices,
13538            &mut image_cmds,
13539        )?;
13540
13541        #[cfg(not(target_arch = "wasm32"))]
13542        if !image_cmds.is_empty() {
13543            self.stage_native_image_buffers(
13544                staged_uploads,
13545                viewport,
13546                &image_vertices,
13547                &image_indices,
13548            );
13549        }
13550
13551        #[cfg(target_arch = "wasm32")]
13552        let image_slot = if image_cmds.is_empty() {
13553            0
13554        } else {
13555            let slot = self.claim_wasm_image_batch();
13556            {
13557                let buffers = &mut self.wasm_image_batches[slot];
13558                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
13559            }
13560            let buffers = &self.wasm_image_batches[slot];
13561            self.write_wasm_buffer(
13562                &buffers.vertex_buffer,
13563                bytemuck::cast_slice(&image_vertices),
13564            );
13565            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
13566            slot
13567        };
13568
13569        #[cfg(target_arch = "wasm32")]
13570        let uniform_slot = if image_cmds.is_empty() {
13571            0
13572        } else {
13573            self.prepare_wasm_viewport_uniforms(viewport)
13574        };
13575
13576        self.scratch_image_vertices = image_vertices;
13577        self.scratch_image_indices = image_indices;
13578        Ok(PreparedImageBatch {
13579            cmds: image_cmds,
13580            #[cfg(target_arch = "wasm32")]
13581            image_slot,
13582            #[cfg(target_arch = "wasm32")]
13583            uniform_slot,
13584        })
13585    }
13586
13587    fn text_raster_geometry(
13588        &self,
13589        text_draw: &TextDraw,
13590        root_scale: f32,
13591    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
13592        text_raster_geometry_for_draw(text_draw, root_scale)
13593    }
13594
13595    fn text_image_cache_key(
13596        text_draw: &TextDraw,
13597        raster_rect: Rect,
13598        text_scale: f32,
13599        static_text_motion: bool,
13600    ) -> TextImageCacheKey {
13601        let mut state = default_hash::new();
13602        text_draw.text.render_hash().hash(&mut state);
13603        text_draw.text_style.render_hash().hash(&mut state);
13604        text_draw.color.render_hash().hash(&mut state);
13605        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
13606        text_draw.font_size.to_bits().hash(&mut state);
13607        text_scale.to_bits().hash(&mut state);
13608        text_draw.layout_options.hash(&mut state);
13609        TextImageCacheKey(state.finish())
13610    }
13611
13612    fn text_glyph_run_cache_key(
13613        text_draw: &TextDraw,
13614        raster_rect: Rect,
13615        text_scale: f32,
13616        static_text_motion: bool,
13617    ) -> TextGlyphRunCacheKey {
13618        TextGlyphRunCacheKey(
13619            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
13620        )
13621    }
13622
13623    fn rasterize_text_draw_to_image(
13624        &mut self,
13625        text_draw: &TextDraw,
13626        raster_rect: Rect,
13627        text_scale: f32,
13628    ) -> Option<ImageBitmap> {
13629        if text_draw.text.span_styles.is_empty() {
13630            let font = self.text_fonts.resolve(&text_draw.text_style)?;
13631            return rasterize_text_to_image_with_glyph_cache(
13632                text_draw.text.text.as_str(),
13633                raster_rect,
13634                &text_draw.text_style,
13635                text_draw.color,
13636                text_draw.font_size,
13637                text_scale,
13638                font,
13639                &mut self.text_glyph_mask_cache,
13640            );
13641        }
13642
13643        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
13644            text_draw.text.as_ref(),
13645            raster_rect,
13646            &text_draw.text_style,
13647            text_draw.color,
13648            text_draw.font_size,
13649            text_scale,
13650            &self.text_fonts,
13651            &mut self.text_glyph_mask_cache,
13652        ) {
13653            return Some(image);
13654        }
13655
13656        rasterize_spanned_text_to_image(
13657            text_draw,
13658            raster_rect,
13659            text_scale,
13660            &self.text_fonts,
13661            &mut self.text_glyph_mask_cache,
13662        )
13663    }
13664}
13665
13666fn rasterize_spanned_text_to_image(
13667    text_draw: &TextDraw,
13668    raster_rect: Rect,
13669    text_scale: f32,
13670    fonts: &SoftwareTextFontSet,
13671    glyph_cache: &mut SoftwareGlyphRasterCache,
13672) -> Option<ImageBitmap> {
13673    let width = raster_rect.width.ceil().max(1.0) as u32;
13674    let height = raster_rect.height.ceil().max(1.0) as u32;
13675    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
13676    let boundaries = text_draw.text.span_boundaries();
13677    let base_line_height = text_draw
13678        .text_style
13679        .resolve_line_height(14.0, text_draw.font_size)
13680        .max(1.0);
13681    let mut current_line_height = base_line_height;
13682    let mut cursor_x = raster_rect.x;
13683    let mut cursor_y = raster_rect.y;
13684
13685    for window in boundaries.windows(2) {
13686        let start = window[0];
13687        let end = window[1];
13688        if start == end {
13689            continue;
13690        }
13691
13692        let chunk = &text_draw.text.text[start..end];
13693        let mut merged_span = text_draw.text_style.span_style.clone();
13694        for span in &text_draw.text.span_styles {
13695            if span.range.start <= start && span.range.end >= end {
13696                merged_span = merged_span.merge(&span.item);
13697            }
13698        }
13699
13700        let mut chunk_style = text_draw.text_style.clone();
13701        chunk_style.span_style = merged_span;
13702
13703        for part in chunk.split_inclusive('\n') {
13704            let has_newline = part.ends_with('\n');
13705            let content = if has_newline {
13706                &part[..part.len().saturating_sub(1)]
13707            } else {
13708                part
13709            };
13710
13711            if !content.is_empty() {
13712                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
13713                let Some(font) = fonts.resolve(&chunk_style) else {
13714                    continue;
13715                };
13716                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
13717                let segment_rect = Rect {
13718                    x: cursor_x,
13719                    y: cursor_y,
13720                    width: (metrics.width * text_scale).ceil().max(1.0),
13721                    height: (metrics.height * text_scale).ceil().max(1.0),
13722                };
13723                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
13724                    content,
13725                    segment_rect,
13726                    &chunk_style,
13727                    chunk_style.resolve_text_color(text_draw.color),
13728                    chunk_font_size,
13729                    text_scale,
13730                    font,
13731                    glyph_cache,
13732                ) {
13733                    composite_text_segment(
13734                        &mut canvas,
13735                        width,
13736                        height,
13737                        raster_rect,
13738                        segment_rect,
13739                        &segment_image,
13740                    );
13741                }
13742                cursor_x += metrics.width * text_scale;
13743                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
13744            }
13745
13746            if has_newline {
13747                cursor_x = raster_rect.x;
13748                cursor_y += current_line_height * text_scale;
13749                current_line_height = base_line_height;
13750            }
13751        }
13752    }
13753
13754    ImageBitmap::from_rgba8(width, height, canvas).ok()
13755}
13756
13757struct TextRasterSource<'a> {
13758    draw: Cow<'a, TextDraw>,
13759    raster_rect: Rect,
13760}
13761
13762fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
13763    TextRasterSource {
13764        draw: Cow::Borrowed(text_draw),
13765        raster_rect,
13766    }
13767}
13768
13769#[cfg(test)]
13770fn clipped_text_raster_source<'a>(
13771    text_draw: &'a TextDraw,
13772    logical_rect: Rect,
13773    raster_rect: Rect,
13774    clip: Option<Rect>,
13775    root_scale: f32,
13776    static_text_motion: bool,
13777) -> TextRasterSource<'a> {
13778    let Some(clip) = clip else {
13779        return TextRasterSource {
13780            draw: Cow::Borrowed(text_draw),
13781            raster_rect,
13782        };
13783    };
13784    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
13785        return TextRasterSource {
13786            draw: Cow::Borrowed(text_draw),
13787            raster_rect,
13788        };
13789    }
13790    let line_starts = line_start_offsets(text_draw.text.text.as_str());
13791    clipped_text_raster_source_with_line_starts(
13792        text_draw,
13793        logical_rect,
13794        raster_rect,
13795        clip,
13796        root_scale,
13797        &line_starts,
13798    )
13799}
13800
13801fn clipped_text_raster_source_with_line_starts<'a>(
13802    text_draw: &'a TextDraw,
13803    logical_rect: Rect,
13804    raster_rect: Rect,
13805    clip: Rect,
13806    root_scale: f32,
13807    line_starts: &[usize],
13808) -> TextRasterSource<'a> {
13809    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
13810        return TextRasterSource {
13811            draw: Cow::Borrowed(text_draw),
13812            raster_rect,
13813        };
13814    }
13815
13816    let Some(visible_rect) = logical_rect.intersect(clip) else {
13817        return TextRasterSource {
13818            draw: Cow::Borrowed(text_draw),
13819            raster_rect,
13820        };
13821    };
13822
13823    let line_count = line_starts.len().max(1);
13824    let line_height = logical_rect.height / line_count as f32;
13825    if !line_height.is_finite() || line_height <= 0.0 {
13826        return TextRasterSource {
13827            draw: Cow::Borrowed(text_draw),
13828            raster_rect,
13829        };
13830    }
13831
13832    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
13833    let visible_bottom =
13834        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
13835    let start_line = visible_top.saturating_sub(1).max(0) as usize;
13836    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
13837    let end_line = end_line.min(line_count);
13838    if start_line == 0 && end_line >= line_count {
13839        return TextRasterSource {
13840            draw: Cow::Borrowed(text_draw),
13841            raster_rect,
13842        };
13843    }
13844
13845    let byte_start = line_starts[start_line];
13846    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
13847    if byte_start >= byte_end {
13848        return TextRasterSource {
13849            draw: Cow::Borrowed(text_draw),
13850            raster_rect,
13851        };
13852    }
13853
13854    let slice_y = logical_rect.y + start_line as f32 * line_height;
13855    let slice_height = (end_line - start_line) as f32 * line_height;
13856    let mut slice_raster_rect = Rect {
13857        x: logical_rect.x * root_scale,
13858        y: slice_y * root_scale,
13859        width: logical_rect.width * root_scale,
13860        height: slice_height * root_scale,
13861    };
13862    slice_raster_rect.x = slice_raster_rect.x.round();
13863    slice_raster_rect.y = slice_raster_rect.y.round();
13864    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
13865    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
13866
13867    let mut sliced_draw = text_draw.clone();
13868    sliced_draw.rect = Rect {
13869        x: logical_rect.x,
13870        y: slice_y,
13871        width: logical_rect.width,
13872        height: slice_height,
13873    };
13874    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
13875
13876    TextRasterSource {
13877        draw: Cow::Owned(sliced_draw),
13878        raster_rect: slice_raster_rect,
13879    }
13880}
13881
13882fn line_start_offsets(text: &str) -> Vec<usize> {
13883    let mut starts =
13884        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
13885    starts.push(0);
13886    starts.extend(
13887        text.char_indices()
13888            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
13889    );
13890    starts
13891}
13892
13893fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
13894    line_starts.get(line + 1).copied().unwrap_or(text.len())
13895}
13896
13897fn composite_text_segment(
13898    canvas: &mut [u8],
13899    canvas_width: u32,
13900    canvas_height: u32,
13901    canvas_rect: Rect,
13902    segment_rect: Rect,
13903    segment_image: &ImageBitmap,
13904) {
13905    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
13906    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
13907    let src = segment_image.pixels();
13908    for sy in 0..segment_image.height() as i32 {
13909        let dy = offset_y + sy;
13910        if dy < 0 || dy >= canvas_height as i32 {
13911            continue;
13912        }
13913        for sx in 0..segment_image.width() as i32 {
13914            let dx = offset_x + sx;
13915            if dx < 0 || dx >= canvas_width as i32 {
13916                continue;
13917            }
13918            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
13919            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
13920            blend_rgba_pixel(
13921                &mut canvas[dst_index..dst_index + 4],
13922                &src[src_index..src_index + 4],
13923            );
13924        }
13925    }
13926}
13927
13928fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
13929    let src_alpha = src[3] as f32 / 255.0;
13930    if src_alpha <= 0.0 {
13931        return;
13932    }
13933    let dst_alpha = dst[3] as f32 / 255.0;
13934    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
13935    if out_alpha <= f32::EPSILON {
13936        dst.copy_from_slice(&[0, 0, 0, 0]);
13937        return;
13938    }
13939
13940    for channel in 0..3 {
13941        let src_channel = src[channel] as f32 / 255.0;
13942        let dst_channel = dst[channel] as f32 / 255.0;
13943        let src_premult = src_channel * src_alpha;
13944        let dst_premult = dst_channel * dst_alpha;
13945        dst[channel] =
13946            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
13947                .round() as u8;
13948    }
13949    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
13950}
13951
13952fn align_to(value: u32, alignment: u32) -> u32 {
13953    debug_assert!(alignment > 0);
13954    value.div_ceil(alignment) * alignment
13955}
13956
13957#[cfg(not(target_arch = "wasm32"))]
13958fn align_usize_to(value: usize, alignment: usize) -> usize {
13959    debug_assert!(alignment > 0);
13960    value.div_ceil(alignment) * alignment
13961}
13962
13963impl GpuRenderer {
13964    fn convert_surface_pixels_to_rgba(&self, pixels: &mut [u8]) -> Result<(), String> {
13965        match self.surface_format {
13966            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => Ok(()),
13967            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
13968                for pixel in pixels.as_chunks_mut::<4>().0 {
13969                    pixel.swap(0, 2);
13970                }
13971                Ok(())
13972            }
13973            format => Err(format!(
13974                "Screenshot readback unsupported for texture format: {format:?}"
13975            )),
13976        }
13977    }
13978}
13979
13980fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
13981    effect_z_ranges.iter().any(|range| range.contains(&z_index))
13982}
13983
13984#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13985enum SegmentDrawItem {
13986    Shape(usize),
13987    Image(usize),
13988    Text(usize),
13989    Shadow(usize),
13990    Composite(usize),
13991    ShaderComposite(usize),
13992    Retained(usize),
13993}
13994
13995#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13996enum SegmentBatchPlan {
13997    Shape {
13998        start: usize,
13999        end: usize,
14000        blend_mode: BlendMode,
14001    },
14002    Image {
14003        start: usize,
14004        end: usize,
14005        blend_mode: BlendMode,
14006    },
14007    Text {
14008        start: usize,
14009        end: usize,
14010    },
14011    Composite {
14012        start: usize,
14013        end: usize,
14014    },
14015    ShaderComposite {
14016        start: usize,
14017        end: usize,
14018    },
14019    /// Retained replay batches: each item is one bind + draw of GPU slots
14020    /// captured on an earlier frame, so they never merge and cost no budget.
14021    Retained {
14022        start: usize,
14023        end: usize,
14024    },
14025}
14026
14027#[derive(Clone, Debug, Default, PartialEq, Eq)]
14028struct SegmentDrawChunkPlan {
14029    batches: Vec<SegmentBatchPlan>,
14030}
14031
14032struct SegmentRenderOutcome {
14033    rendered_any: bool,
14034    pass_count: u32,
14035}
14036
14037struct SegmentCommandEncodeOutcome {
14038    first_batch: bool,
14039}
14040
14041#[cfg(not(target_arch = "wasm32"))]
14042#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14043enum TextGlyphPrewarmDecision {
14044    Candidate,
14045    MissingGeometry,
14046    DynamicMotion,
14047    Visible,
14048    OutsidePrewarmWindow,
14049}
14050
14051#[cfg(not(target_arch = "wasm32"))]
14052#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14053struct NativeSegmentFusionBudget {
14054    shape_count: usize,
14055    gradient_stop_count: usize,
14056}
14057
14058#[cfg(not(target_arch = "wasm32"))]
14059#[derive(Clone, Debug, PartialEq, Eq)]
14060struct NativeSegmentFusionPartition {
14061    chunk: SegmentDrawChunkPlan,
14062    budget: NativeSegmentFusionBudget,
14063}
14064
14065#[cfg(not(target_arch = "wasm32"))]
14066#[derive(Clone, Debug, PartialEq, Eq)]
14067enum FusedSegmentBatch {
14068    Shape {
14069        batch: PreparedShapeBatch,
14070        blend_mode: BlendMode,
14071    },
14072    Image {
14073        cmd_range: Range<usize>,
14074        blend_mode: BlendMode,
14075    },
14076    Text {
14077        image_cmd_range: Range<usize>,
14078        glyph_cmd_range: Range<usize>,
14079    },
14080    Composite {
14081        draw_range: Range<usize>,
14082    },
14083    ShaderComposite {
14084        draw_range: Range<usize>,
14085    },
14086    Retained {
14087        item_range: Range<usize>,
14088    },
14089}
14090
14091struct ShadowSourceRenderOutcome {
14092    rendered_any: bool,
14093    pass_count: u32,
14094}
14095
14096impl SegmentDrawChunkPlan {
14097    fn is_empty(&self) -> bool {
14098        self.batches.is_empty()
14099    }
14100
14101    fn push(&mut self, batch: SegmentBatchPlan) {
14102        self.batches.push(batch);
14103    }
14104
14105    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
14106        self.batches.iter().copied()
14107    }
14108}
14109
14110#[derive(Clone, Debug, PartialEq, Eq)]
14111enum SegmentRenderCommand {
14112    DrawChunk(SegmentDrawChunkPlan),
14113    Shadow(usize),
14114}
14115
14116struct SegmentCommandIter<'a> {
14117    ordered_items: &'a [(usize, SegmentDrawItem)],
14118    shapes: &'a [DrawShape],
14119    images: &'a [ImageDraw],
14120    cursor: usize,
14121    batch_limits: ShapeBatchLimits,
14122}
14123
14124impl<'a> SegmentCommandIter<'a> {
14125    fn new(
14126        ordered_items: &'a [(usize, SegmentDrawItem)],
14127        shapes: &'a [DrawShape],
14128        images: &'a [ImageDraw],
14129        batch_limits: ShapeBatchLimits,
14130    ) -> Self {
14131        Self {
14132            ordered_items,
14133            shapes,
14134            images,
14135            cursor: 0,
14136            batch_limits,
14137        }
14138    }
14139}
14140
14141impl Iterator for SegmentCommandIter<'_> {
14142    type Item = SegmentRenderCommand;
14143
14144    fn next(&mut self) -> Option<Self::Item> {
14145        if self.cursor >= self.ordered_items.len() {
14146            return None;
14147        }
14148
14149        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
14150            self.cursor += 1;
14151            return Some(SegmentRenderCommand::Shadow(index));
14152        }
14153
14154        let mut chunk = SegmentDrawChunkPlan::default();
14155        while self.cursor < self.ordered_items.len() {
14156            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
14157                if chunk.is_empty() {
14158                    self.cursor += 1;
14159                    return Some(SegmentRenderCommand::Shadow(index));
14160                }
14161                break;
14162            }
14163
14164            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
14165                self.ordered_items,
14166                self.shapes,
14167                self.images,
14168                self.cursor,
14169                self.batch_limits,
14170            ) else {
14171                break;
14172            };
14173            chunk.push(batch);
14174            self.cursor = next_cursor;
14175        }
14176
14177        Some(SegmentRenderCommand::DrawChunk(chunk))
14178    }
14179}
14180
14181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14182struct PreparedShapeBatch {
14183    /// First vertex and vertex count for the unindexed shape draw; always
14184    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
14185    vertex_start: u32,
14186    vertex_count: u32,
14187    /// Whether any shape in the batch carries gradient stops. False routes
14188    /// a SrcOver draw through the `fs_solid` pipeline.
14189    has_gradient: bool,
14190    #[cfg(target_arch = "wasm32")]
14191    shape_slot: usize,
14192    #[cfg(target_arch = "wasm32")]
14193    uniform_slot: usize,
14194}
14195
14196struct PreparedImageBatch {
14197    cmds: Vec<ImageDrawCmd>,
14198    #[cfg(target_arch = "wasm32")]
14199    image_slot: usize,
14200    #[cfg(target_arch = "wasm32")]
14201    uniform_slot: usize,
14202}
14203
14204impl PreparedImageBatch {
14205    fn is_empty(&self) -> bool {
14206        self.cmds.is_empty()
14207    }
14208
14209    fn into_cmds(self) -> Vec<ImageDrawCmd> {
14210        self.cmds
14211    }
14212}
14213
14214struct PreparedGlyphBatch {
14215    cmds: Vec<GlyphDrawCmd>,
14216    #[cfg(target_arch = "wasm32")]
14217    image_slot: usize,
14218    #[cfg(target_arch = "wasm32")]
14219    uniform_slot: usize,
14220}
14221
14222impl PreparedGlyphBatch {
14223    fn is_empty(&self) -> bool {
14224        self.cmds.is_empty()
14225    }
14226
14227    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
14228        self.cmds
14229    }
14230}
14231
14232#[cfg(not(target_arch = "wasm32"))]
14233fn gradient_stop_count_for_shape(shape: &DrawShape, brushes: &[Brush]) -> usize {
14234    match shape.brush {
14235        SceneBrush::Solid(_) => 0,
14236        SceneBrush::Gradient(index) => match &brushes[index as usize] {
14237            Brush::Solid(_) => 0,
14238            Brush::LinearGradient { colors, .. }
14239            | Brush::RadialGradient { colors, .. }
14240            | Brush::SweepGradient { colors, .. } => colors.len(),
14241        },
14242    }
14243}
14244
14245#[cfg(not(target_arch = "wasm32"))]
14246fn native_segment_fusion_budget(
14247    ordered_items: &[(usize, SegmentDrawItem)],
14248    shapes: &[DrawShape],
14249    brushes: &[Brush],
14250    chunk: &SegmentDrawChunkPlan,
14251    batch_limits: ShapeBatchLimits,
14252) -> Result<Option<NativeSegmentFusionBudget>, String> {
14253    let mut shape_count = 0usize;
14254    let mut gradient_stop_count = 0usize;
14255
14256    for batch in chunk.iter() {
14257        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
14258            continue;
14259        };
14260        for (_, item) in &ordered_items[start..end] {
14261            let SegmentDrawItem::Shape(shape_index) = item else {
14262                return Err(format!(
14263                    "shape batch contains non-shape draw item: {item:?}"
14264                ));
14265            };
14266            let shape = &shapes[*shape_index];
14267            shape_count = shape_count.saturating_add(1);
14268            gradient_stop_count =
14269                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape, brushes));
14270        }
14271    }
14272
14273    if shape_count > batch_limits.max_shapes_per_batch
14274        || gradient_stop_count > batch_limits.max_gradient_stops
14275    {
14276        return Ok(None);
14277    }
14278
14279    Ok(Some(NativeSegmentFusionBudget {
14280        shape_count,
14281        gradient_stop_count,
14282    }))
14283}
14284
14285#[cfg(not(target_arch = "wasm32"))]
14286fn push_native_segment_fusion_partition(
14287    partitions: &mut Vec<NativeSegmentFusionPartition>,
14288    current: &mut SegmentDrawChunkPlan,
14289    current_budget: &mut NativeSegmentFusionBudget,
14290) {
14291    if current.is_empty() {
14292        return;
14293    }
14294
14295    partitions.push(NativeSegmentFusionPartition {
14296        chunk: std::mem::take(current),
14297        budget: *current_budget,
14298    });
14299    *current_budget = NativeSegmentFusionBudget {
14300        shape_count: 0,
14301        gradient_stop_count: 0,
14302    };
14303}
14304
14305#[cfg(not(target_arch = "wasm32"))]
14306fn native_segment_fusion_partitions(
14307    ordered_items: &[(usize, SegmentDrawItem)],
14308    shapes: &[DrawShape],
14309    brushes: &[Brush],
14310    chunk: &SegmentDrawChunkPlan,
14311    batch_limits: ShapeBatchLimits,
14312) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
14313    if let Some(budget) =
14314        native_segment_fusion_budget(ordered_items, shapes, brushes, chunk, batch_limits)?
14315    {
14316        return Ok(Some(vec![NativeSegmentFusionPartition {
14317            chunk: chunk.clone(),
14318            budget,
14319        }]));
14320    }
14321
14322    let mut partitions = Vec::new();
14323    let mut current = SegmentDrawChunkPlan::default();
14324    let mut current_budget = NativeSegmentFusionBudget {
14325        shape_count: 0,
14326        gradient_stop_count: 0,
14327    };
14328
14329    for batch in chunk.iter() {
14330        let SegmentBatchPlan::Shape {
14331            start,
14332            end,
14333            blend_mode,
14334        } = batch
14335        else {
14336            current.push(batch);
14337            continue;
14338        };
14339
14340        let mut run_start = start;
14341        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
14342            let SegmentDrawItem::Shape(shape_index) = *item else {
14343                return Err(format!(
14344                    "shape batch contains non-shape draw item: {:?}",
14345                    item
14346                ));
14347            };
14348            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index], brushes);
14349            if gradient_stop_count > batch_limits.max_gradient_stops {
14350                return Ok(None);
14351            }
14352
14353            let fits_shape_count =
14354                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
14355            let fits_gradient_count = current_budget
14356                .gradient_stop_count
14357                .saturating_add(gradient_stop_count)
14358                <= batch_limits.max_gradient_stops;
14359            if !fits_shape_count || !fits_gradient_count {
14360                if run_start < item_cursor {
14361                    current.push(SegmentBatchPlan::Shape {
14362                        start: run_start,
14363                        end: item_cursor,
14364                        blend_mode,
14365                    });
14366                }
14367                push_native_segment_fusion_partition(
14368                    &mut partitions,
14369                    &mut current,
14370                    &mut current_budget,
14371                );
14372                run_start = item_cursor;
14373            }
14374
14375            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
14376            current_budget.gradient_stop_count = current_budget
14377                .gradient_stop_count
14378                .saturating_add(gradient_stop_count);
14379        }
14380
14381        if run_start < end {
14382            current.push(SegmentBatchPlan::Shape {
14383                start: run_start,
14384                end,
14385                blend_mode,
14386            });
14387        }
14388    }
14389
14390    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
14391    Ok(Some(partitions))
14392}
14393
14394fn segment_batch_plan_at_cursor(
14395    ordered_items: &[(usize, SegmentDrawItem)],
14396    shapes: &[DrawShape],
14397    images: &[ImageDraw],
14398    start: usize,
14399    batch_limits: ShapeBatchLimits,
14400) -> Option<(SegmentBatchPlan, usize)> {
14401    match ordered_items[start].1 {
14402        SegmentDrawItem::Shape(index) => {
14403            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
14404            let mut end = start + 1;
14405            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
14406            while end < shape_limit {
14407                match ordered_items[end].1 {
14408                    SegmentDrawItem::Shape(next_index)
14409                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
14410                    {
14411                        end += 1;
14412                    }
14413                    _ => break,
14414                }
14415            }
14416            Some((
14417                SegmentBatchPlan::Shape {
14418                    start,
14419                    end,
14420                    blend_mode,
14421                },
14422                end,
14423            ))
14424        }
14425        SegmentDrawItem::Image(index) => {
14426            let blend_mode = supported_blend_mode(images[index].blend_mode);
14427            let mut end = start + 1;
14428            while end < ordered_items.len() {
14429                match ordered_items[end].1 {
14430                    SegmentDrawItem::Image(next_index)
14431                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
14432                    {
14433                        end += 1;
14434                    }
14435                    _ => break,
14436                }
14437            }
14438            Some((
14439                SegmentBatchPlan::Image {
14440                    start,
14441                    end,
14442                    blend_mode,
14443                },
14444                end,
14445            ))
14446        }
14447        SegmentDrawItem::Text(_) => {
14448            let mut end = start + 1;
14449            while end < ordered_items.len() {
14450                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
14451                    end += 1;
14452                } else {
14453                    break;
14454                }
14455            }
14456            Some((SegmentBatchPlan::Text { start, end }, end))
14457        }
14458        SegmentDrawItem::Composite(_) => {
14459            let mut end = start + 1;
14460            while end < ordered_items.len() {
14461                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
14462                    end += 1;
14463                } else {
14464                    break;
14465                }
14466            }
14467            Some((SegmentBatchPlan::Composite { start, end }, end))
14468        }
14469        SegmentDrawItem::ShaderComposite(_) => {
14470            let mut end = start + 1;
14471            while end < ordered_items.len() {
14472                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
14473                    end += 1;
14474                } else {
14475                    break;
14476                }
14477            }
14478            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
14479        }
14480        SegmentDrawItem::Retained(_) => {
14481            let mut end = start + 1;
14482            while end < ordered_items.len() {
14483                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
14484                    end += 1;
14485                } else {
14486                    break;
14487                }
14488            }
14489            Some((SegmentBatchPlan::Retained { start, end }, end))
14490        }
14491        SegmentDrawItem::Shadow(_) => None,
14492    }
14493}
14494
14495#[allow(clippy::too_many_arguments)]
14496fn collect_non_effect_segment_items(
14497    shapes: &[DrawShape],
14498    _images: &[ImageDraw],
14499    _texts: &[TextDraw],
14500    _shadow_draws: &[ShadowDraw],
14501    draw_ops: &[DrawOp],
14502    z_start: usize,
14503    z_end: usize,
14504    effect_z_ranges: &[Range<usize>],
14505    width: u32,
14506    height: u32,
14507    root_scale: f32,
14508    scratch: &mut Vec<(usize, SegmentDrawItem)>,
14509) {
14510    scratch.clear();
14511    let viewport = ViewportUniformParams {
14512        width,
14513        height,
14514        offset: [0.0, 0.0],
14515    };
14516
14517    scratch.extend(draw_ops.iter().filter_map(|op| {
14518        if op.z_index < z_start
14519            || op.z_index >= z_end
14520            || is_in_effect_range(op.z_index, effect_z_ranges)
14521        {
14522            return None;
14523        }
14524        let item = match op.kind {
14525            DrawOpKind::Shape(index) => {
14526                let shape = shapes.get(index)?;
14527                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
14528                    return None;
14529                }
14530                SegmentDrawItem::Shape(index)
14531            }
14532            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
14533            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
14534            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
14535            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
14536        };
14537        Some((op.z_index, item))
14538    }));
14539}
14540
14541fn retain_renderable_shadow_items(
14542    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
14543    shadow_draws: &[ShadowDraw],
14544    width: u32,
14545    height: u32,
14546    root_scale: f32,
14547    max_texture_dim: u32,
14548) -> usize {
14549    let original_len = ordered_items.len();
14550    ordered_items.retain(|(_, item)| match item {
14551        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
14552            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
14553        }),
14554        _ => true,
14555    });
14556    original_len.saturating_sub(ordered_items.len())
14557}
14558
14559#[cfg(not(target_arch = "wasm32"))]
14560#[derive(Clone, Copy)]
14561struct SegmentDiagCounts {
14562    raw_shadow_items: usize,
14563    culled_shadow_items: usize,
14564    cached_shadow_composites: usize,
14565    composite_items: usize,
14566    shader_composite_items: usize,
14567}
14568
14569#[cfg(not(target_arch = "wasm32"))]
14570fn maybe_print_segment_diag(
14571    z_range: Range<usize>,
14572    ordered_items: &[(usize, SegmentDrawItem)],
14573    shapes: &[DrawShape],
14574    brushes: &[Brush],
14575    images: &[ImageDraw],
14576    counts: SegmentDiagCounts,
14577    batch_limits: ShapeBatchLimits,
14578) {
14579    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
14580        return;
14581    }
14582    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
14583    if line >= 64 {
14584        return;
14585    }
14586
14587    let remaining_shadow_items = ordered_items
14588        .iter()
14589        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
14590        .count();
14591    let commands: Vec<_> =
14592        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
14593    let draw_chunks = commands
14594        .iter()
14595        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
14596        .count();
14597    let shadow_commands = commands
14598        .iter()
14599        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
14600        .count();
14601    let mut native_partitions = 0usize;
14602    let mut native_unfused_chunks = 0usize;
14603    for command in &commands {
14604        let SegmentRenderCommand::DrawChunk(chunk) = command else {
14605            continue;
14606        };
14607        match native_segment_fusion_partitions(ordered_items, shapes, brushes, chunk, batch_limits)
14608        {
14609            Ok(Some(partitions)) => native_partitions += partitions.len(),
14610            Ok(None) | Err(_) => native_unfused_chunks += 1,
14611        }
14612    }
14613
14614    eprintln!(
14615        "[segment-diag #{line}] z={}..{} items={} raw_shadows={} culled_shadows={} cached_shadows={} remaining_shadows={} composites={} shader_composites={} draw_chunks={} shadow_commands={} native_partitions={} native_unfused_chunks={}",
14616        z_range.start,
14617        z_range.end,
14618        ordered_items.len(),
14619        counts.raw_shadow_items,
14620        counts.culled_shadow_items,
14621        counts.cached_shadow_composites,
14622        remaining_shadow_items,
14623        counts.composite_items,
14624        counts.shader_composite_items,
14625        draw_chunks,
14626        shadow_commands,
14627        native_partitions,
14628        native_unfused_chunks,
14629    );
14630}
14631
14632pub(crate) fn has_backdrop_layer_in_range(
14633    backdrop_layers: &[BackdropLayer],
14634    z_start: usize,
14635    z_end: usize,
14636) -> bool {
14637    backdrop_layers
14638        .iter()
14639        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
14640}
14641
14642pub(crate) fn scissor_rect_for_rect(
14643    rect: Rect,
14644    root_scale: f32,
14645    width: u32,
14646    height: u32,
14647) -> Option<(u32, u32, u32, u32)> {
14648    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
14649    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
14650    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
14651    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
14652
14653    left = left.max(0.0).min(width as f32).floor();
14654    top = top.max(0.0).min(height as f32).floor();
14655    right = right.max(0.0).min(width as f32).ceil();
14656    bottom = bottom.max(0.0).min(height as f32).ceil();
14657
14658    if right <= left || bottom <= top {
14659        return None;
14660    }
14661
14662    Some((
14663        left as u32,
14664        top as u32,
14665        (right - left) as u32,
14666        (bottom - top) as u32,
14667    ))
14668}
14669
14670fn scissor_rect_for_layer(
14671    rect: Rect,
14672    clip: Option<Rect>,
14673    root_scale: f32,
14674    width: u32,
14675    height: u32,
14676) -> Option<(u32, u32, u32, u32)> {
14677    let clipped_rect = match clip {
14678        Some(clip_rect) => rect.intersect(clip_rect)?,
14679        None => rect,
14680    };
14681
14682    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
14683}
14684
14685fn tint_for_image(
14686    color_filter: Option<ColorFilter>,
14687    alpha: f32,
14688) -> ([f32; 4], Option<ColorFilter>) {
14689    let alpha = alpha.clamp(0.0, 1.0);
14690    match color_filter {
14691        Some(filter) if filter.supports_gpu_vertex_modulation() => {
14692            let Some(tint) = filter.gpu_vertex_tint() else {
14693                return ([1.0, 1.0, 1.0, alpha], Some(filter));
14694            };
14695            (
14696                [
14697                    tint[0].clamp(0.0, 1.0),
14698                    tint[1].clamp(0.0, 1.0),
14699                    tint[2].clamp(0.0, 1.0),
14700                    (tint[3] * alpha).clamp(0.0, 1.0),
14701                ],
14702                None,
14703            )
14704        }
14705        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
14706        None => ([1.0, 1.0, 1.0, alpha], None),
14707    }
14708}
14709
14710fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
14711    let Some(src) = src_rect else {
14712        return Some(ImageUvRect {
14713            min: [0.0, 0.0],
14714            max: [1.0, 1.0],
14715            sample_bounds: [0.0, 0.0, 1.0, 1.0],
14716        });
14717    };
14718
14719    let (u_min, u_max, u_bound_min, u_bound_max) =
14720        source_axis_uv(src.x, src.width, image.width() as f32)?;
14721    let (v_min, v_max, v_bound_min, v_bound_max) =
14722        source_axis_uv(src.y, src.height, image.height() as f32)?;
14723
14724    Some(ImageUvRect {
14725        min: [u_min, v_min],
14726        max: [u_max, v_max],
14727        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
14728    })
14729}
14730
14731/// Normalises an atlas entry against `atlas_size`, the side length of the
14732/// texture the entry was placed in. The atlas grows on overflow, so the size
14733/// has to be read from the live atlas rather than a constant — a UV computed
14734/// against the wrong size samples the wrong glyph.
14735fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
14736    let atlas_width = atlas_size as f32;
14737    let atlas_height = atlas_size as f32;
14738    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
14739    let max = [
14740        (entry.x + entry.width) as f32 / atlas_width,
14741        (entry.y + entry.height) as f32 / atlas_height,
14742    ];
14743    let center_min = [
14744        (entry.x as f32 + 0.5) / atlas_width,
14745        (entry.y as f32 + 0.5) / atlas_height,
14746    ];
14747    let center_max = [
14748        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
14749        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
14750    ];
14751    ImageUvRect {
14752        min,
14753        max,
14754        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
14755    }
14756}
14757
14758fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
14759    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
14760        return;
14761    }
14762
14763    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
14764        return;
14765    };
14766
14767    let left_px = (rect.x * root_scale).round();
14768    let top_px = (rect.y * root_scale).round();
14769    let width_px = (rect.width * root_scale).round().max(1.0);
14770    let height_px = (rect.height * root_scale).round().max(1.0);
14771    let snapped = Rect {
14772        x: left_px / root_scale,
14773        y: top_px / root_scale,
14774        width: width_px / root_scale,
14775        height: height_px / root_scale,
14776    };
14777
14778    image.rect = snapped;
14779    image.local_rect = Rect {
14780        x: image.local_rect.x + snapped.x - rect.x,
14781        y: image.local_rect.y + snapped.y - rect.y,
14782        width: snapped.width,
14783        height: snapped.height,
14784    };
14785    image.quad = crate::rect_to_quad(snapped);
14786}
14787
14788fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
14789    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
14790        return None;
14791    }
14792
14793    let rect = axis_aligned_quad_rect(image.quad)?;
14794    let left_px = (rect.x * root_scale).round();
14795    let top_px = (rect.y * root_scale).round();
14796    let width_px = (rect.width * root_scale).round().max(1.0);
14797    let height_px = (rect.height * root_scale).round().max(1.0);
14798    let right_px = left_px + width_px;
14799    let bottom_px = top_px + height_px;
14800    Some([
14801        [left_px, top_px],
14802        [right_px, top_px],
14803        [left_px, bottom_px],
14804        [right_px, bottom_px],
14805    ])
14806}
14807
14808fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
14809    if !start.is_finite()
14810        || !extent.is_finite()
14811        || !image_extent.is_finite()
14812        || extent == 0.0
14813        || image_extent <= 0.0
14814    {
14815        return None;
14816    }
14817
14818    let end = start + extent;
14819    let edge_min = start.min(end).clamp(0.0, image_extent);
14820    let edge_max = start.max(end).clamp(0.0, image_extent);
14821    if edge_max <= edge_min {
14822        return None;
14823    }
14824
14825    let center_min = edge_min + 0.5;
14826    let center_max = edge_max - 0.5;
14827    let (bound_min, bound_max) = if center_min <= center_max {
14828        (center_min, center_max)
14829    } else {
14830        let center = (edge_min + edge_max) * 0.5;
14831        (center, center)
14832    };
14833
14834    Some((
14835        edge_min / image_extent,
14836        edge_max / image_extent,
14837        bound_min / image_extent,
14838        bound_max / image_extent,
14839    ))
14840}
14841
14842fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
14843    let mut filtered = Vec::with_capacity(image.pixels().len());
14844    for pixel in image.pixels().as_chunks::<4>().0 {
14845        let rgba = [
14846            pixel[0] as f32 / 255.0,
14847            pixel[1] as f32 / 255.0,
14848            pixel[2] as f32 / 255.0,
14849            pixel[3] as f32 / 255.0,
14850        ];
14851        let out = filter.apply_rgba(rgba);
14852        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
14853        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
14854        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
14855        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
14856    }
14857    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
14858        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
14859}
14860
14861fn scissor_rect_for_image(
14862    image: &ImageDraw,
14863    root_scale: f32,
14864    width: u32,
14865    height: u32,
14866) -> Option<(u32, u32, u32, u32)> {
14867    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
14868}
14869
14870fn inner_shadow_composite_mask(
14871    shadow: &ShadowDraw,
14872    root_scale: f32,
14873) -> Option<RoundedCompositeMask> {
14874    if !shadow
14875        .shapes
14876        .iter()
14877        .any(|(_, mode)| *mode == BlendMode::DstOut)
14878    {
14879        return None;
14880    }
14881    let (fill, _) = shadow.shapes.first()?;
14882    let rect = fill.local_rect;
14883    if rect.width <= 0.0 || rect.height <= 0.0 {
14884        return None;
14885    }
14886
14887    let radii = fill.shape.map_or([0.0; 4], |rounded| {
14888        let resolved = rounded.resolve(rect.width, rect.height);
14889        [
14890            resolved.top_left * root_scale,
14891            resolved.top_right * root_scale,
14892            resolved.bottom_left * root_scale,
14893            resolved.bottom_right * root_scale,
14894        ]
14895    });
14896
14897    Some(RoundedCompositeMask {
14898        rect: [
14899            rect.x * root_scale,
14900            rect.y * root_scale,
14901            rect.width * root_scale,
14902            rect.height * root_scale,
14903        ],
14904        radii,
14905    })
14906}
14907
14908#[cfg(test)]
14909mod tests {
14910    use super::*;
14911    use crate::normalized_scene::visible_draw_rect;
14912    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
14913    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
14914    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
14915    use cranpose_render_common::scene_builder::build_graph_from_applier;
14916    use cranpose_ui::text::{
14917        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
14918        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
14919    };
14920    use cranpose_ui::{
14921        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
14922        TextStyle,
14923    };
14924    use cranpose_ui_graphics::{
14925        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
14926        RuntimeShader,
14927    };
14928
14929    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
14930        let mut chunk = SegmentDrawChunkPlan::default();
14931        for batch in batches {
14932            chunk.push(*batch);
14933        }
14934        chunk
14935    }
14936
14937    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
14938        let app_context = cranpose_ui::AppContext::new();
14939        app_context.enter(block)
14940    }
14941
14942    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
14943        let Some(actual) = actual else {
14944            panic!("{message}: missing snap anchor");
14945        };
14946        let expected = SnapAnchor::rigid(expected_origin);
14947        assert_eq!(
14948            actual.device_pixel_step, expected.device_pixel_step,
14949            "{message}: device pixel step changed"
14950        );
14951        assert!(
14952            (actual.origin.x - expected.origin.x).abs() <= 1e-4
14953                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
14954            "{message}: expected origin {:?}, got {:?}",
14955            expected.origin,
14956            actual.origin
14957        );
14958    }
14959
14960    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
14961        EffectLayer {
14962            rect: Rect {
14963                x: 0.0,
14964                y: 0.0,
14965                width: 10.0,
14966                height: 10.0,
14967            },
14968            clip: None,
14969            snap_anchor: None,
14970            effect: Some(RenderEffect::blur(4.0)),
14971            blend_mode: BlendMode::SrcOver,
14972            composite_alpha: 1.0,
14973            z_start,
14974            z_end,
14975            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
14976        }
14977    }
14978
14979    #[test]
14980    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
14981        assert_eq!(
14982            direct_shader_composite_viewport(
14983                1.0,
14984                BlendMode::SrcOver,
14985                Some((12.0, 18.0, 64.0, 32.0)),
14986                CompositeSampleMode::Box4,
14987                (64, 32),
14988            ),
14989            Some((12.0, 18.0, 64.0, 32.0))
14990        );
14991    }
14992
14993    #[test]
14994    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
14995        assert_eq!(
14996            direct_shader_composite_viewport(
14997                1.0,
14998                BlendMode::SrcOver,
14999                Some((12.0, 18.0, 64.5, 32.0)),
15000                CompositeSampleMode::Box4,
15001                (64, 32),
15002            ),
15003            None
15004        );
15005        assert_eq!(
15006            direct_shader_composite_viewport(
15007                1.0,
15008                BlendMode::SrcOver,
15009                Some((12.25, 18.0, 64.0, 32.0)),
15010                CompositeSampleMode::Box4,
15011                (64, 32),
15012            ),
15013            None
15014        );
15015    }
15016
15017    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
15018        let mut text_style = TextStyle::default();
15019        text_style.paragraph_style.text_motion = Some(text_motion);
15020        TextDraw {
15021            node_id: 42,
15022            rect,
15023            snap_anchor: None,
15024            translated_content_context: false,
15025            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
15026            color: Color::WHITE,
15027            text_style,
15028            font_size: 14.0,
15029            scale: 1.0,
15030            layout_options: TextLayoutOptions::default(),
15031            z_index: 0,
15032            clip: None,
15033        }
15034    }
15035
15036    #[test]
15037    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
15038        let base = test_text_draw(
15039            Rect {
15040                x: 12.25,
15041                y: 40.75,
15042                width: 220.0,
15043                height: 24.0,
15044            },
15045            TextMotion::Static,
15046        );
15047        let scrolled = test_text_draw(
15048            Rect {
15049                x: 12.75,
15050                y: -318.5,
15051                width: 220.0,
15052                height: 24.0,
15053            },
15054            TextMotion::Static,
15055        );
15056
15057        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
15058        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
15059
15060        assert_eq!(
15061            base_key, scrolled_key,
15062            "scrolling static text must reuse the same raster cache entry"
15063        );
15064    }
15065
15066    #[test]
15067    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
15068        let base = test_text_draw(
15069            Rect {
15070                x: 12.25,
15071                y: 40.75,
15072                width: 220.0,
15073                height: 24.0,
15074            },
15075            TextMotion::Static,
15076        );
15077        let scrolled = test_text_draw(
15078            Rect {
15079                x: 12.75,
15080                y: -318.5,
15081                width: 220.0,
15082                height: 24.0,
15083            },
15084            TextMotion::Static,
15085        );
15086
15087        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
15088        let scrolled_key =
15089            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
15090
15091        assert_eq!(
15092            base_key, scrolled_key,
15093            "scrolling static text must reuse the same retained glyph run"
15094        );
15095    }
15096
15097    #[test]
15098    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
15099        let rect = Rect {
15100            x: 8.0,
15101            y: 100.0,
15102            width: 240.0,
15103            height: 1_000.0,
15104        };
15105        let mut draw = test_text_draw(rect, TextMotion::Static);
15106        let lines = (0..100)
15107            .map(|line| format!("line-{line:03}"))
15108            .collect::<Vec<_>>()
15109            .join("\n");
15110        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
15111
15112        let raster_rect = Rect {
15113            x: 16.0,
15114            y: 200.0,
15115            width: 480.0,
15116            height: 2_000.0,
15117        };
15118        let clipped = clipped_text_raster_source(
15119            &draw,
15120            rect,
15121            raster_rect,
15122            Some(Rect {
15123                x: 0.0,
15124                y: 610.0,
15125                width: 800.0,
15126                height: 40.0,
15127            }),
15128            2.0,
15129            true,
15130        );
15131        let glyph = text_glyph_raster_source(&draw, raster_rect);
15132
15133        assert!(
15134            matches!(clipped.draw, Cow::Owned(_)),
15135            "the image source should still slice large clipped multiline text"
15136        );
15137        assert!(
15138            matches!(glyph.draw, Cow::Borrowed(_)),
15139            "the glyph source must keep a stable full-text run key while scrolling"
15140        );
15141
15142        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
15143            clipped.draw.as_ref(),
15144            clipped.raster_rect,
15145            2.0,
15146            true,
15147        );
15148        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
15149            glyph.draw.as_ref(),
15150            glyph.raster_rect,
15151            2.0,
15152            true,
15153        );
15154
15155        assert_ne!(
15156            clipped_key, glyph_key,
15157            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
15158        );
15159    }
15160
15161    #[cfg(not(target_arch = "wasm32"))]
15162    #[test]
15163    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
15164        let viewport = ViewportUniformParams {
15165            width: 800,
15166            height: 600,
15167            offset: [10.0, 20.0],
15168        };
15169        let source = Rect {
15170            x: 40.0,
15171            y: 90.0,
15172            width: 120.0,
15173            height: 48.0,
15174        };
15175
15176        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
15177
15178        assert_eq!(retained.width, viewport.width);
15179        assert_eq!(retained.height, viewport.height);
15180        assert_eq!(retained.offset, [-30.0, -70.0]);
15181    }
15182
15183    #[cfg(not(target_arch = "wasm32"))]
15184    #[test]
15185    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
15186        assert!(
15187            !should_use_retained_text_glyph_run(8, None),
15188            "tiny labels must stay in the shared fused batch"
15189        );
15190    }
15191
15192    #[cfg(not(target_arch = "wasm32"))]
15193    #[test]
15194    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
15195        assert!(
15196            !should_use_retained_text_glyph_run(64, None),
15197            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
15198        );
15199    }
15200
15201    #[cfg(not(target_arch = "wasm32"))]
15202    #[test]
15203    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
15204        assert!(
15205            !should_use_retained_text_glyph_run(
15206                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
15207                Some(Rect {
15208                    x: 0.0,
15209                    y: 0.0,
15210                    width: 200.0,
15211                    height: 100.0,
15212                }),
15213            ),
15214            "clipped lazy-list text must not draw a full retained run outside the viewport"
15215        );
15216    }
15217
15218    #[test]
15219    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
15220        assert_eq!(
15221            text_glyph_draw_action(false, true, false),
15222            TextGlyphDrawAction::Skip,
15223            "normal draw traversal must not prepare offscreen text"
15224        );
15225    }
15226
15227    #[test]
15228    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
15229        assert_eq!(
15230            text_glyph_draw_action(false, true, true),
15231            TextGlyphDrawAction::PrewarmOffscreen,
15232            "only the bounded prewarm path may prepare offscreen text"
15233        );
15234    }
15235
15236    #[test]
15237    fn visible_text_glyph_draws_are_always_admitted() {
15238        assert_eq!(
15239            text_glyph_draw_action(true, false, false),
15240            TextGlyphDrawAction::DrawVisible
15241        );
15242        assert_eq!(
15243            text_glyph_draw_action(true, true, true),
15244            TextGlyphDrawAction::DrawVisible
15245        );
15246    }
15247
15248    #[cfg(not(target_arch = "wasm32"))]
15249    #[test]
15250    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
15251        assert!(
15252            !offscreen_text_glyph_prewarm_work_is_bounded(
15253                None,
15254                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
15255            ),
15256            "offscreen prewarm must not collect large uncached text runs in an input frame"
15257        );
15258    }
15259
15260    #[cfg(not(target_arch = "wasm32"))]
15261    #[test]
15262    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
15263        assert!(
15264            offscreen_text_glyph_prewarm_work_is_bounded(
15265                None,
15266                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
15267            ),
15268            "small labels can be warmed without risking a frame-budget spike"
15269        );
15270    }
15271
15272    #[cfg(not(target_arch = "wasm32"))]
15273    #[test]
15274    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
15275        assert!(
15276            !offscreen_text_glyph_prewarm_work_is_bounded(
15277                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
15278                0,
15279            ),
15280            "cached glyph placements can still be too large to prepare during input frames"
15281        );
15282    }
15283
15284    #[cfg(not(target_arch = "wasm32"))]
15285    #[test]
15286    fn offscreen_text_prewarm_stops_after_candidate_budget() {
15287        assert!(
15288            offscreen_text_glyph_prewarm_budget_exhausted(
15289                Instant::now(),
15290                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
15291            ),
15292            "prewarm must be bounded by candidate count even when each candidate is cheap"
15293        );
15294    }
15295
15296    #[test]
15297    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
15298        fn quad(y: i32) -> CachedTextGlyphQuad {
15299            CachedTextGlyphQuad {
15300                x: 8,
15301                y,
15302                width: 20,
15303                height: 10,
15304                color: (1.0, 1.0, 1.0, 1.0),
15305                uv: ImageUvRect {
15306                    min: [0.0, 0.0],
15307                    max: [1.0, 1.0],
15308                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
15309                },
15310            }
15311        }
15312
15313        let source = Rect {
15314            x: 0.0,
15315            y: 0.0,
15316            width: 320.0,
15317            height: 400.0,
15318        };
15319        let clip = Some(Rect {
15320            x: 0.0,
15321            y: 0.0,
15322            width: 320.0,
15323            height: 80.0,
15324        });
15325        let viewport = ViewportUniformParams {
15326            width: 320,
15327            height: 80,
15328            offset: [0.0, 0.0],
15329        };
15330
15331        assert!(cached_text_glyph_quad_is_visible_in_viewport(
15332            source,
15333            &quad(40),
15334            clip,
15335            viewport,
15336            1.0,
15337        ));
15338        assert!(
15339            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
15340            "glyphs outside the effective clip should not enter the frame command stream"
15341        );
15342    }
15343
15344    #[test]
15345    fn small_scene_range_cache_miss_observes_first_render() {
15346        let key = LayerRasterCacheKey::scene_range(
15347            0xCACE,
15348            Rect {
15349                x: 0.0,
15350                y: 0.0,
15351                width: 120.0,
15352                height: 80.0,
15353            },
15354            (120, 80),
15355            ScaleBucket::from_scale(1.0),
15356        );
15357
15358        assert!(
15359            !first_cache_miss_admission(&key),
15360            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
15361        );
15362        assert!(
15363            repeated_cache_miss_admission(&key),
15364            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
15365        );
15366    }
15367
15368    #[test]
15369    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
15370        let key = LayerRasterCacheKey::scene_range(
15371            0xCACE,
15372            Rect {
15373                x: 0.0,
15374                y: 0.0,
15375                width: 1200.0,
15376                height: 900.0,
15377            },
15378            (1200, 900),
15379            ScaleBucket::from_scale(1.0),
15380        );
15381
15382        assert!(
15383            !first_cache_miss_admission(&key),
15384            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
15385        );
15386        assert!(
15387            repeated_cache_miss_admission(&key),
15388            "a repeated scene-range miss is stable enough to materialize into the retained cache"
15389        );
15390    }
15391
15392    #[test]
15393    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
15394        let stats = gpu_stats::FrameStats::default();
15395        let mut snapshot = stats.snapshot();
15396        assert!(
15397            !frame_stats_need_warmup_frame(&snapshot),
15398            "a clean frame must not keep a static scene redrawing"
15399        );
15400
15401        snapshot.layer_cache_misses = 1;
15402        assert!(frame_stats_need_warmup_frame(&snapshot));
15403        snapshot.layer_cache_misses = 0;
15404
15405        snapshot.shadow_shape_cache_misses = 1;
15406        assert!(frame_stats_need_warmup_frame(&snapshot));
15407        snapshot.shadow_shape_cache_misses = 0;
15408
15409        snapshot.text_image_cache_misses = 1;
15410        assert!(frame_stats_need_warmup_frame(&snapshot));
15411        snapshot.text_image_cache_misses = 0;
15412
15413        snapshot.text_glyph_atlas_misses = 1;
15414        assert!(frame_stats_need_warmup_frame(&snapshot));
15415    }
15416
15417    #[test]
15418    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
15419        let stats = gpu_stats::FrameStats::default();
15420        let mut snapshot = stats.snapshot();
15421        snapshot.layer_cache_misses = 1;
15422        let mut pending_frames = 0;
15423
15424        update_frame_warmup_budget(&mut pending_frames, &snapshot);
15425        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
15426
15427        update_frame_warmup_budget(&mut pending_frames, &snapshot);
15428        assert_eq!(
15429            pending_frames, 0,
15430            "a cache miss during the warmup frame must not replenish its budget"
15431        );
15432    }
15433
15434    #[test]
15435    fn non_scene_layer_surface_cache_miss_admits_first_render() {
15436        let key = LayerRasterCacheKey::new(
15437            Some(77),
15438            0xC0FFEE,
15439            0,
15440            Rect {
15441                x: 0.0,
15442                y: 0.0,
15443                width: 120.0,
15444                height: 80.0,
15445            },
15446            (120, 80),
15447            ScaleBucket::from_scale(1.0),
15448        );
15449
15450        assert!(
15451            first_cache_miss_admission(&key),
15452            "ordinary retained layer surfaces should still cache on first miss"
15453        );
15454    }
15455
15456    #[test]
15457    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
15458        let first = test_text_draw(
15459            Rect {
15460                x: 12.25,
15461                y: 40.75,
15462                width: 220.0,
15463                height: 24.0,
15464            },
15465            TextMotion::Static,
15466        );
15467        let mut second = first.clone();
15468        second.node_id = first.node_id + 1;
15469
15470        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
15471        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
15472
15473        assert_eq!(
15474            first_key, second_key,
15475            "text raster cache keys must be based on rendered pixels, not node identity"
15476        );
15477    }
15478
15479    #[test]
15480    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
15481        let base = test_text_draw(
15482            Rect {
15483                x: 12.25,
15484                y: 40.75,
15485                width: 220.0,
15486                height: 24.0,
15487            },
15488            TextMotion::Animated,
15489        );
15490        let integer_translated = test_text_draw(
15491            Rect {
15492                x: 44.25,
15493                y: 88.75,
15494                width: 220.0,
15495                height: 24.0,
15496            },
15497            TextMotion::Animated,
15498        );
15499        let phase_shifted = test_text_draw(
15500            Rect {
15501                x: 44.5,
15502                y: 88.75,
15503                width: 220.0,
15504                height: 24.0,
15505            },
15506            TextMotion::Animated,
15507        );
15508
15509        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
15510        let translated_key = GpuRenderer::text_image_cache_key(
15511            &integer_translated,
15512            integer_translated.rect,
15513            1.0,
15514            false,
15515        );
15516        let phase_shifted_key =
15517            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
15518
15519        assert_eq!(
15520            base_key, translated_key,
15521            "integer translation should not invalidate animated text raster cache entries"
15522        );
15523        assert_ne!(
15524            base_key, phase_shifted_key,
15525            "fractional phase affects animated text rasterization and must stay in the key"
15526        );
15527    }
15528
15529    #[test]
15530    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
15531        let mut base = test_text_draw(
15532            Rect {
15533                x: 14.25,
15534                y: 16.50,
15535                width: 220.0,
15536                height: 24.0,
15537            },
15538            TextMotion::Animated,
15539        );
15540        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
15541
15542        let mut scrolled = test_text_draw(
15543            Rect {
15544                x: 14.25,
15545                y: 15.80,
15546                width: 220.0,
15547                height: 24.0,
15548            },
15549            TextMotion::Animated,
15550        );
15551        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
15552
15553        let (base_logical, base_raster, _, _, base_static) =
15554            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
15555        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
15556            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
15557
15558        assert!(!base_static);
15559        assert!(!scrolled_static);
15560        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
15561        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
15562        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
15563        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
15564        assert_eq!(base_raster.x.fract(), 0.0);
15565        assert_eq!(base_raster.y.fract(), 0.0);
15566        assert_eq!(scrolled_raster.x.fract(), 0.0);
15567        assert_eq!(scrolled_raster.y.fract(), 0.0);
15568
15569        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
15570        let scrolled_key =
15571            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
15572        assert_eq!(
15573            base_key, scrolled_key,
15574            "translated animated text should keep a stable raster phase while scrolling"
15575        );
15576    }
15577
15578    #[test]
15579    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
15580        let root_scale = 1.25;
15581        let mut base = test_text_draw(
15582            Rect {
15583                x: 14.0,
15584                y: 276.0,
15585                width: 220.0,
15586                height: 24.0,
15587            },
15588            TextMotion::Static,
15589        );
15590        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
15591
15592        let mut scrolled = test_text_draw(
15593            Rect {
15594                x: 14.0,
15595                y: 275.2,
15596                width: 220.0,
15597                height: 24.0,
15598            },
15599            TextMotion::Static,
15600        );
15601        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
15602
15603        let (_, base_raster, _, _, _) =
15604            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
15605        let (_, scrolled_raster, _, _, _) =
15606            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
15607
15608        assert_eq!(
15609            base_raster.y - scrolled_raster.y,
15610            1.0,
15611            "one physical pixel of rigid scrolling must move static text by one raster pixel"
15612        );
15613    }
15614
15615    #[test]
15616    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
15617        let root_scale = 1.25;
15618        let fixed_clip = Rect {
15619            x: 8.0,
15620            y: 20.0,
15621            width: 300.0,
15622            height: 680.0,
15623        };
15624        let mut draw = test_text_draw(
15625            Rect {
15626                x: 14.0,
15627                y: 276.0,
15628                width: 220.0,
15629                height: 24.0,
15630            },
15631            TextMotion::Static,
15632        );
15633        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
15634        draw.clip = Some(fixed_clip);
15635
15636        let (_, _, clip, _, _) =
15637            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
15638
15639        assert_eq!(
15640            clip,
15641            Some(fixed_clip),
15642            "content pixel snapping must not translate a fixed ancestor clip"
15643        );
15644    }
15645
15646    #[test]
15647    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
15648        let rect = Rect {
15649            x: 8.0,
15650            y: 100.0,
15651            width: 240.0,
15652            height: 1_000.0,
15653        };
15654        let mut draw = test_text_draw(rect, TextMotion::Static);
15655        let lines = (0..100)
15656            .map(|line| format!("line-{line:03}"))
15657            .collect::<Vec<_>>()
15658            .join("\n");
15659        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
15660
15661        let raster_rect = Rect {
15662            x: 16.0,
15663            y: 200.0,
15664            width: 480.0,
15665            height: 2_000.0,
15666        };
15667        let source = clipped_text_raster_source(
15668            &draw,
15669            rect,
15670            raster_rect,
15671            Some(Rect {
15672                x: 0.0,
15673                y: 610.0,
15674                width: 800.0,
15675                height: 40.0,
15676            }),
15677            2.0,
15678            true,
15679        );
15680
15681        let Cow::Owned(sliced_draw) = source.draw else {
15682            panic!("clipped static multiline text should rasterize only the visible line window");
15683        };
15684        let sliced_text = sliced_draw.text.text.as_str();
15685        assert!(sliced_text.contains("line-050"));
15686        assert!(sliced_text.contains("line-055"));
15687        assert!(!sliced_text.contains("line-000"));
15688        assert!(!sliced_text.contains("line-099"));
15689        assert_eq!(source.raster_rect.x, raster_rect.x);
15690        assert!(source.raster_rect.y > raster_rect.y);
15691        assert!(source.raster_rect.height < raster_rect.height);
15692    }
15693
15694    #[test]
15695    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
15696        let rect = Rect {
15697            x: 8.0,
15698            y: 100.0,
15699            width: 240.0,
15700            height: 320.0,
15701        };
15702        let mut draw = test_text_draw(rect, TextMotion::Static);
15703        let lines = (0..24)
15704            .map(|line| format!("code-line-{line:02}"))
15705            .collect::<Vec<_>>()
15706            .join("\n");
15707        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
15708
15709        let raster_rect = Rect {
15710            x: 16.0,
15711            y: 200.0,
15712            width: 480.0,
15713            height: 640.0,
15714        };
15715        let source = clipped_text_raster_source(
15716            &draw,
15717            rect,
15718            raster_rect,
15719            Some(Rect {
15720                x: 0.0,
15721                y: 190.0,
15722                width: 800.0,
15723                height: 120.0,
15724            }),
15725            2.0,
15726            true,
15727        );
15728
15729        let Cow::Owned(sliced_draw) = source.draw else {
15730            panic!("clipped multiline text should rasterize only the visible line window");
15731        };
15732        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
15733        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
15734        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
15735        assert_eq!(source.raster_rect.x, raster_rect.x);
15736        assert!(source.raster_rect.y > raster_rect.y);
15737        assert!(source.raster_rect.height < raster_rect.height);
15738    }
15739
15740    #[test]
15741    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
15742        let mut cache = TextLineIndexCache::new(4);
15743        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
15744
15745        let first = cache.line_starts(&text);
15746        let second = cache.line_starts(&text);
15747
15748        assert_eq!(first.as_ref(), &[0, 2, 4]);
15749        assert!(
15750            Rc::ptr_eq(&first, &second),
15751            "retained text should not rebuild its line index on every clipped frame"
15752        );
15753    }
15754
15755    #[test]
15756    fn text_line_index_cache_is_retained_text_instance_local() {
15757        let mut cache = TextLineIndexCache::new(4);
15758        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
15759        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
15760
15761        let first = cache.line_starts(&first_text);
15762        let second = cache.line_starts(&second_text);
15763
15764        assert_eq!(first.as_ref(), second.as_ref());
15765        assert!(
15766            !Rc::ptr_eq(&first, &second),
15767            "line index lookup should not hash large text contents to find unrelated retained nodes"
15768        );
15769    }
15770
15771    #[test]
15772    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
15773        let bounds = device_pixel_bounds_for_rect(
15774            Rect {
15775                x: 10.25,
15776                y: 14.6,
15777                width: 20.1,
15778                height: 9.2,
15779            },
15780            200,
15781            120,
15782            2.0,
15783        )
15784        .expect("rect should intersect the viewport");
15785
15786        assert_eq!(
15787            bounds,
15788            DevicePixelBounds {
15789                x: 20.0,
15790                y: 29.0,
15791                width: 41,
15792                height: 19,
15793            }
15794        );
15795    }
15796
15797    #[test]
15798    fn visible_layer_rect_intersects_clip_and_viewport() {
15799        let visible = visible_layer_rect(
15800            Rect {
15801                x: -10.0,
15802                y: 5.0,
15803                width: 80.0,
15804                height: 40.0,
15805            },
15806            Some(Rect {
15807                x: 4.0,
15808                y: 8.0,
15809                width: 20.0,
15810                height: 50.0,
15811            }),
15812            2.0,
15813            60,
15814            40,
15815        )
15816        .expect("visible rect");
15817
15818        assert_eq!(
15819            visible,
15820            Rect {
15821                x: 4.0,
15822                y: 8.0,
15823                width: 20.0,
15824                height: 12.0,
15825            }
15826        );
15827    }
15828
15829    #[test]
15830    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
15831        let clamped = clamp_effect_surface_scale(
15832            Rect {
15833                x: 0.0,
15834                y: 0.0,
15835                width: 1200.0,
15836                height: 900.0,
15837            },
15838            1.0,
15839            8.0,
15840            16_384,
15841        );
15842
15843        assert!(
15844            clamped < 8.0,
15845            "large translated effect layers must be capped to avoid OOM, got {clamped}"
15846        );
15847        assert!(
15848            clamped >= 1.0,
15849            "effect surfaces must not fall below destination resolution, got {clamped}"
15850        );
15851    }
15852
15853    #[test]
15854    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
15855        let clamped = clamp_effect_surface_scale(
15856            Rect {
15857                x: 0.0,
15858                y: 0.0,
15859                width: 446.0,
15860                height: 44.0,
15861            },
15862            1.0,
15863            9.0,
15864            16_384,
15865        );
15866
15867        assert_eq!(
15868            clamped, 9.0,
15869            "decorated text motion-stable captures must keep full scale"
15870        );
15871    }
15872
15873    fn backdrop_layer(z_index: usize) -> BackdropLayer {
15874        BackdropLayer {
15875            node_id: Some(700 + z_index),
15876            rect: Rect {
15877                x: 0.0,
15878                y: 0.0,
15879                width: 10.0,
15880                height: 10.0,
15881            },
15882            clip: None,
15883            snap_anchor: None,
15884            effect: RenderEffect::blur(2.0),
15885            z_index,
15886        }
15887    }
15888
15889    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
15890        DrawShape {
15891            rect: Rect {
15892                x: 0.0,
15893                y: 0.0,
15894                width: 8.0,
15895                height: 8.0,
15896            },
15897            local_rect: Rect {
15898                x: 0.0,
15899                y: 0.0,
15900                width: 8.0,
15901                height: 8.0,
15902            },
15903            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
15904            snap_anchor: None,
15905            brush: SceneBrush::Solid(Color::BLACK),
15906            shape: None,
15907            stroke: None,
15908            arc: None,
15909            z_index,
15910            clip: None,
15911            blend_mode,
15912            motion_context_animated: false,
15913        }
15914    }
15915
15916    #[test]
15917    fn shape_shadow_content_hash_ignores_viewport_translation() {
15918        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
15919            let mut translated = *shape;
15920            translated.rect.x += dx;
15921            translated.rect.y += dy;
15922            translated.local_rect.x += dx;
15923            translated.local_rect.y += dy;
15924            for point in &mut translated.quad {
15925                point[0] += dx;
15926                point[1] += dy;
15927            }
15928            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
15929                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
15930            });
15931            translated.clip = translated.clip.map(|mut clip| {
15932                clip.x += dx;
15933                clip.y += dy;
15934                clip
15935            });
15936            translated
15937        }
15938
15939        let mut first = test_shape(1, BlendMode::SrcOver);
15940        first.rect = Rect {
15941            x: 10.0,
15942            y: 20.0,
15943            width: 80.0,
15944            height: 40.0,
15945        };
15946        first.local_rect = first.rect;
15947        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
15948        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
15949        first.shape = Some(RoundedCornerShape::uniform(8.0));
15950        first.clip = Some(Rect {
15951            x: 8.0,
15952            y: 18.0,
15953            width: 86.0,
15954            height: 44.0,
15955        });
15956        let mut cutout = test_shape(2, BlendMode::DstOut);
15957        cutout.rect = Rect {
15958            x: 18.0,
15959            y: 26.0,
15960            width: 62.0,
15961            height: 22.0,
15962        };
15963        cutout.local_rect = cutout.rect;
15964        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
15965        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
15966
15967        let dx = 37.0;
15968        let dy = -11.5;
15969        let translated = translate_shape(&first, dx, dy);
15970        let translated_cutout = translate_shape(&cutout, dx, dy);
15971
15972        let root_scale = 1.25;
15973        let first_shapes = vec![(first, BlendMode::SrcOver), (cutout, BlendMode::DstOut)];
15974        let translated_shapes = vec![
15975            (translated, BlendMode::SrcOver),
15976            (translated_cutout, BlendMode::DstOut),
15977        ];
15978
15979        let first_hash = shape_shadow_content_hash(&first_shapes, &[], root_scale);
15980        let translated_hash = shape_shadow_content_hash(&translated_shapes, &[], root_scale);
15981
15982        assert_eq!(first_hash, translated_hash);
15983
15984        let mut changed_shapes = translated_shapes;
15985        changed_shapes[0].0.rect.width += 1.0;
15986        let changed_hash = shape_shadow_content_hash(&changed_shapes, &[], root_scale);
15987
15988        assert_ne!(first_hash, changed_hash);
15989    }
15990
15991    #[test]
15992    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
15993        // Regression: scrolling a shadowed panel on a fractional-scale display
15994        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
15995        // every frame. The production cache key derives its viewport offset from
15996        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
15997        // into the content hash and miss the cache on every scroll step.
15998        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
15999            let mut shape = test_shape(1, BlendMode::SrcOver);
16000            shape.rect = Rect {
16001                x: 24.0,
16002                y,
16003                width: 180.0,
16004                height: 90.0,
16005            };
16006            shape.local_rect = shape.rect;
16007            shape.quad = crate::rect_to_quad(shape.rect);
16008            shape.shape = Some(RoundedCornerShape::uniform(14.0));
16009            vec![(shape, BlendMode::SrcOver)]
16010        }
16011
16012        let root_scale = 130.0f32 / 96.0;
16013        let blur_radius = 18.0f32;
16014        let pixel_radius = blur_radius * root_scale;
16015
16016        let key_at = |y: f32| {
16017            let shapes = shadow_shapes_at(y);
16018            let plan =
16019                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
16020                    .expect("surface plan");
16021            shape_shadow_surface_cache_key(
16022                &shapes,
16023                &[],
16024                plan.source_device_bounds,
16025                pixel_radius,
16026                root_scale,
16027            )
16028            .expect("cache key")
16029        };
16030
16031        // Wheel scroll translates the panel by whole logical pixels; the device
16032        // subpixel phase changes on every step at fractional scale. The whole
16033        // cache key (content hash AND surface pixel size) must stay stable, or
16034        // every scroll frame re-renders the shadow blur.
16035        let base = key_at(640.0);
16036        for step in 1..=12 {
16037            let scrolled = key_at(640.0 - step as f32 * 4.0);
16038            assert_eq!(
16039                base, scrolled,
16040                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
16041            );
16042        }
16043    }
16044
16045    #[test]
16046    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
16047        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
16048            let mut shape = test_shape(1, BlendMode::SrcOver);
16049            shape.rect = Rect {
16050                x: 24.0,
16051                y,
16052                width: 280.0,
16053                height: 120.0,
16054            };
16055            shape.local_rect = shape.rect;
16056            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
16057            shape.shape = Some(RoundedCornerShape::uniform(18.0));
16058            vec![(shape, BlendMode::SrcOver)]
16059        }
16060
16061        let root_scale = 1.0;
16062        let blur_radius = 18.0;
16063        let viewport_clip = Rect {
16064            x: 0.0,
16065            y: 96.0,
16066            width: 360.0,
16067            height: 720.0,
16068        };
16069        let key_for = |y: f32| {
16070            let shapes = translated_card_shadow(y);
16071            let plan = shape_shadow_surface_plan(
16072                &shapes,
16073                Some(viewport_clip),
16074                blur_radius,
16075                360,
16076                900,
16077                root_scale,
16078                4096,
16079            )
16080            .expect("surface plan");
16081            shape_shadow_surface_cache_key(
16082                &shapes,
16083                &[],
16084                plan.source_device_bounds,
16085                plan.pixel_radius,
16086                root_scale,
16087            )
16088            .expect("cache key")
16089        };
16090
16091        // The card scrolls under a fixed viewport clip; the visible portion
16092        // changes but the cache key must stay anchored to the unclipped source.
16093        assert_eq!(key_for(740.0), key_for(756.0));
16094    }
16095
16096    #[test]
16097    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
16098        let mut shape = test_shape(1, BlendMode::SrcOver);
16099        shape.rect = Rect {
16100            x: 24.0,
16101            y: 740.0,
16102            width: 280.0,
16103            height: 120.0,
16104        };
16105        shape.local_rect = shape.rect;
16106        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
16107        let viewport = ViewportUniformParams {
16108            width: 316,
16109            height: 228,
16110            offset: [6.0, 686.0],
16111        };
16112
16113        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
16114    }
16115
16116    #[test]
16117    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
16118        let viewport = ViewportUniformParams {
16119            width: 316,
16120            height: 228,
16121            offset: [6.0, 686.0],
16122        };
16123        let text_rect = Rect {
16124            x: 24.0,
16125            y: 740.0,
16126            width: 280.0,
16127            height: 40.0,
16128        };
16129
16130        assert!(text_draw_is_visible_in_viewport(
16131            text_rect, None, viewport, 1.0
16132        ));
16133        assert!(text_draw_should_prewarm_in_viewport(
16134            text_rect, None, viewport, 1.0
16135        ));
16136    }
16137
16138    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
16139        ShadowDraw {
16140            shapes,
16141            brushes: vec![],
16142            texts: vec![],
16143            blur_radius: 8.0,
16144            clip: None,
16145            z_index: 0,
16146        }
16147    }
16148
16149    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
16150        ImageDraw {
16151            rect: Rect {
16152                x: 0.0,
16153                y: 0.0,
16154                width: 8.0,
16155                height: 8.0,
16156            },
16157            local_rect: Rect {
16158                x: 0.0,
16159                y: 0.0,
16160                width: 8.0,
16161                height: 8.0,
16162            },
16163            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
16164            snap_anchor: None,
16165            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
16166            alpha: 1.0,
16167            color_filter: None,
16168            sampling: ImageSampling::Nearest,
16169            z_index,
16170            clip: None,
16171            blend_mode,
16172            src_rect: None,
16173            motion_context_animated: false,
16174        }
16175    }
16176
16177    #[test]
16178    fn image_sampler_descriptors_match_requested_sampling() {
16179        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
16180        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
16181        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
16182
16183        let linear = image_sampler_descriptor(ImageSampling::Linear);
16184        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
16185        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
16186    }
16187
16188    #[test]
16189    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
16190        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
16191        let uv = image_uv_rect(
16192            &image,
16193            Some(Rect {
16194                x: 0.0,
16195                y: 0.0,
16196                width: 16.0,
16197                height: 16.0,
16198            }),
16199        )
16200        .expect("uv rect");
16201
16202        assert_eq!(uv.min, [0.0, 0.0]);
16203        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
16204        assert_eq!(
16205            uv.sample_bounds,
16206            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
16207        );
16208    }
16209
16210    #[test]
16211    fn image_uv_rect_keeps_full_image_unclamped() {
16212        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
16213        let uv = image_uv_rect(&image, None).expect("uv rect");
16214
16215        assert_eq!(uv.min, [0.0, 0.0]);
16216        assert_eq!(uv.max, [1.0, 1.0]);
16217        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
16218    }
16219
16220    fn test_text(z_index: usize) -> TextDraw {
16221        TextDraw {
16222            node_id: 0,
16223            rect: Rect {
16224                x: 0.0,
16225                y: 0.0,
16226                width: 8.0,
16227                height: 8.0,
16228            },
16229            snap_anchor: None,
16230            translated_content_context: false,
16231            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
16232            color: Color::WHITE,
16233            text_style: cranpose_ui::TextStyle::default(),
16234            font_size: 12.0,
16235            scale: 1.0,
16236            layout_options: cranpose_ui::TextLayoutOptions::default(),
16237            z_index,
16238            clip: None,
16239        }
16240    }
16241
16242    #[test]
16243    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
16244        let viewport = ViewportUniformParams {
16245            width: 320,
16246            height: 240,
16247            offset: [0.0, 0.0],
16248        };
16249        let text_rect = Rect {
16250            x: 0.0,
16251            y: 260.0,
16252            width: 200.0,
16253            height: 40.0,
16254        };
16255        let clip = Some(Rect {
16256            x: 0.0,
16257            y: 0.0,
16258            width: 320.0,
16259            height: 200.0,
16260        });
16261
16262        assert!(
16263            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
16264            "lazy-list beyond-bound text outside the clip must not be rasterized"
16265        );
16266    }
16267
16268    #[test]
16269    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
16270        let viewport = ViewportUniformParams {
16271            width: 320,
16272            height: 240,
16273            offset: [0.0, 0.0],
16274        };
16275        let text_rect = Rect {
16276            x: 0.0,
16277            y: 260.0,
16278            width: 200.0,
16279            height: 40.0,
16280        };
16281        let clip = Some(Rect {
16282            x: 0.0,
16283            y: 0.0,
16284            width: 320.0,
16285            height: 200.0,
16286        });
16287
16288        assert!(!text_draw_is_visible_in_viewport(
16289            text_rect, clip, viewport, 1.0
16290        ));
16291        assert!(text_draw_should_prewarm_in_viewport(
16292            text_rect, clip, viewport, 1.0
16293        ));
16294    }
16295
16296    #[test]
16297    fn text_draw_prewarm_rejects_far_clipped_text() {
16298        let viewport = ViewportUniformParams {
16299            width: 320,
16300            height: 240,
16301            offset: [0.0, 0.0],
16302        };
16303        let text_rect = Rect {
16304            x: 0.0,
16305            y: 1600.0,
16306            width: 200.0,
16307            height: 40.0,
16308        };
16309        let clip = Some(Rect {
16310            x: 0.0,
16311            y: 0.0,
16312            width: 320.0,
16313            height: 200.0,
16314        });
16315
16316        assert!(!text_draw_should_prewarm_in_viewport(
16317            text_rect, clip, viewport, 1.0
16318        ));
16319    }
16320
16321    #[test]
16322    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
16323        let viewport = ViewportUniformParams {
16324            width: 320,
16325            height: 240,
16326            offset: [0.0, 0.0],
16327        };
16328        let text_rect = Rect {
16329            x: 0.0,
16330            y: 241.0,
16331            width: 200.0,
16332            height: 40.0,
16333        };
16334
16335        assert!(
16336            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
16337            "unclipped text outside the target viewport must not be rasterized"
16338        );
16339    }
16340
16341    #[test]
16342    fn text_draw_visibility_keeps_partially_visible_text() {
16343        let viewport = ViewportUniformParams {
16344            width: 320,
16345            height: 240,
16346            offset: [0.0, 0.0],
16347        };
16348        let text_rect = Rect {
16349            x: 0.0,
16350            y: 220.0,
16351            width: 200.0,
16352            height: 40.0,
16353        };
16354
16355        assert!(text_draw_is_visible_in_viewport(
16356            text_rect, None, viewport, 1.0
16357        ));
16358    }
16359
16360    fn test_draw_ops(
16361        shapes: &[DrawShape],
16362        images: &[ImageDraw],
16363        texts: &[TextDraw],
16364        shadows: &[ShadowDraw],
16365    ) -> Vec<DrawOp> {
16366        let mut ops = Vec::new();
16367        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
16368            z_index: shape.z_index,
16369            kind: DrawOpKind::Shape(index),
16370        }));
16371        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
16372            z_index: image.z_index,
16373            kind: DrawOpKind::Image(index),
16374        }));
16375        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
16376            z_index: text.z_index,
16377            kind: DrawOpKind::Text(index),
16378        }));
16379        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
16380            z_index: shadow.z_index,
16381            kind: DrawOpKind::Shadow(index),
16382        }));
16383        ops.sort_by_key(|op| op.z_index);
16384        ops
16385    }
16386
16387    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
16388        crate::test_support::layer_node(
16389            local_bounds,
16390            ProjectiveTransform::identity(),
16391            GraphicsLayer::default(),
16392            children,
16393        )
16394    }
16395
16396    fn cacheable_layer(
16397        node_id: cranpose_core::NodeId,
16398        local_bounds: Rect,
16399        children: Vec<RenderNode>,
16400    ) -> LayerNode {
16401        let mut layer = test_layer(local_bounds, children);
16402        layer.node_id = Some(node_id);
16403        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
16404        layer.recompute_raster_cache_hashes();
16405        layer
16406    }
16407
16408    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
16409        test_layer(
16410            Rect {
16411                x: 0.0,
16412                y: 0.0,
16413                width: 64.0,
16414                height: 32.0,
16415            },
16416            vec![RenderNode::Primitive(PrimitiveEntry {
16417                phase: PrimitivePhase::BeforeChildren,
16418                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
16419                    node_id: 1,
16420                    rect: Rect {
16421                        x: 2.0,
16422                        y: 3.0,
16423                        width: 48.0,
16424                        height: 18.0,
16425                    },
16426                    text: std::rc::Rc::new(text),
16427                    text_style,
16428                    font_size: 14.0,
16429                    layout_options: TextLayoutOptions::default(),
16430                    clip: None,
16431                })),
16432            })],
16433        )
16434    }
16435
16436    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
16437        LayerNode {
16438            node_id: Some(77),
16439            local_bounds: Rect {
16440                x: 0.0,
16441                y: 0.0,
16442                width: 48.0,
16443                height: 24.0,
16444            },
16445            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
16446            motion_context_animated: animated,
16447            translated_content_context,
16448            translated_content_offset: Point::default(),
16449            content_offset: Point::default(),
16450            scene_children_origin: cranpose_ui_graphics::Point::default(),
16451            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
16452            graphics_layer: GraphicsLayer::default(),
16453            clip_to_bounds: false,
16454            shadow_clip: None,
16455            hit_test: None,
16456            has_hit_targets: false,
16457            isolation: IsolationReasons::default(),
16458            cache_policy: CachePolicy::None,
16459            cache_hashes: LayerRasterCacheHashes::default(),
16460            cache_hashes_valid: false,
16461            children: vec![
16462                RenderNode::Primitive(PrimitiveEntry {
16463                    phase: PrimitivePhase::BeforeChildren,
16464                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
16465                        primitive: DrawPrimitive::RoundRect {
16466                            rect: Rect {
16467                                x: 0.0,
16468                                y: 0.0,
16469                                width: 48.0,
16470                                height: 24.0,
16471                            },
16472                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
16473                            radii: CornerRadii::uniform(6.0),
16474                            stroke: None,
16475                        },
16476                        clip: None,
16477                    }),
16478                }),
16479                RenderNode::Primitive(PrimitiveEntry {
16480                    phase: PrimitivePhase::BeforeChildren,
16481                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
16482                        primitive: DrawPrimitive::Image {
16483                            rect: Rect {
16484                                x: 2.0,
16485                                y: 2.0,
16486                                width: 12.0,
16487                                height: 12.0,
16488                            },
16489                            image: ImageBitmap::from_rgba8(
16490                                2,
16491                                2,
16492                                vec![
16493                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
16494                                    255,
16495                                ],
16496                            )
16497                            .expect("image"),
16498                            alpha: 1.0,
16499                            color_filter: None,
16500                            sampling: ImageSampling::Linear,
16501                            src_rect: None,
16502                        },
16503                        clip: None,
16504                    }),
16505                }),
16506                RenderNode::Primitive(PrimitiveEntry {
16507                    phase: PrimitivePhase::BeforeChildren,
16508                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
16509                        node_id: 77,
16510                        rect: Rect {
16511                            x: 6.0,
16512                            y: 4.0,
16513                            width: 36.0,
16514                            height: 16.0,
16515                        },
16516                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
16517                        text_style: TextStyle::default(),
16518                        font_size: 14.0,
16519                        layout_options: TextLayoutOptions::default(),
16520                        clip: None,
16521                    })),
16522                }),
16523            ],
16524        }
16525    }
16526
16527    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
16528        let text_leaf = snapped_text_leaf(animated, translated_content_context);
16529        test_layer(
16530            Rect {
16531                x: 0.0,
16532                y: 0.0,
16533                width: 96.0,
16534                height: 64.0,
16535            },
16536            vec![RenderNode::Layer(Box::new(text_leaf))],
16537        )
16538    }
16539
16540    fn translated_content_local_surface_root() -> LayerNode {
16541        let mut effectful_text = text_layer_with_style(
16542            AnnotatedString::from("shadow"),
16543            TextStyle::from_span_style(SpanStyle {
16544                shadow: Some(Shadow {
16545                    color: Color::BLACK,
16546                    offset: Point::new(1.0, 2.0),
16547                    blur_radius: 3.0,
16548                }),
16549                ..SpanStyle::default()
16550            }),
16551        );
16552        effectful_text.translated_content_context = true;
16553
16554        let translated_content = LayerNode {
16555            node_id: Some(78),
16556            local_bounds: Rect {
16557                x: 0.0,
16558                y: 0.0,
16559                width: 96.0,
16560                height: 64.0,
16561            },
16562            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
16563            motion_context_animated: false,
16564            translated_content_context: true,
16565            translated_content_offset: Point::default(),
16566            content_offset: Point::default(),
16567            scene_children_origin: cranpose_ui_graphics::Point::default(),
16568            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
16569            graphics_layer: GraphicsLayer::default(),
16570            clip_to_bounds: false,
16571            shadow_clip: None,
16572            hit_test: None,
16573            has_hit_targets: false,
16574            isolation: IsolationReasons::default(),
16575            cache_policy: CachePolicy::None,
16576            cache_hashes: LayerRasterCacheHashes::default(),
16577            cache_hashes_valid: false,
16578            children: vec![RenderNode::Layer(Box::new(effectful_text))],
16579        };
16580
16581        test_layer(
16582            Rect {
16583                x: 0.0,
16584                y: 0.0,
16585                width: 160.0,
16586                height: 120.0,
16587            },
16588            vec![RenderNode::Layer(Box::new(translated_content))],
16589        )
16590    }
16591
16592    #[test]
16593    fn scissor_rect_for_layer_intersects_with_clip() {
16594        let rect = Rect {
16595            x: 10.0,
16596            y: 10.0,
16597            width: 30.0,
16598            height: 20.0,
16599        };
16600        let clip = Rect {
16601            x: 20.0,
16602            y: 15.0,
16603            width: 100.0,
16604            height: 100.0,
16605        };
16606
16607        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
16608        assert_eq!(scissor, Some((20, 15, 20, 15)));
16609    }
16610
16611    #[test]
16612    fn visible_draw_rect_no_clip_returns_original() {
16613        let rect = Rect {
16614            x: 100.0,
16615            y: 200.0,
16616            width: 300.0,
16617            height: 400.0,
16618        };
16619        assert_eq!(visible_draw_rect(rect, None), Some(rect));
16620    }
16621
16622    #[test]
16623    fn visible_draw_rect_with_clip_intersects() {
16624        let rect = Rect {
16625            x: 0.0,
16626            y: 0.0,
16627            width: 2000.0,
16628            height: 5000.0,
16629        };
16630        let clip = Rect {
16631            x: 0.0,
16632            y: 0.0,
16633            width: 800.0,
16634            height: 600.0,
16635        };
16636        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
16637        assert_eq!(visible.width, 800.0);
16638        assert_eq!(visible.height, 600.0);
16639    }
16640
16641    #[test]
16642    fn visible_draw_rect_fully_clipped_returns_none() {
16643        let rect = Rect {
16644            x: 1000.0,
16645            y: 1000.0,
16646            width: 200.0,
16647            height: 200.0,
16648        };
16649        let clip = Rect {
16650            x: 0.0,
16651            y: 0.0,
16652            width: 800.0,
16653            height: 600.0,
16654        };
16655        assert!(visible_draw_rect(rect, Some(clip)).is_none());
16656    }
16657
16658    #[test]
16659    fn scene_bounds_respects_clip_on_shapes() {
16660        let mut scene = CompositorScene::new();
16661        // Shape inside viewport — visible
16662        scene.shapes.push(DrawShape {
16663            rect: Rect {
16664                x: 10.0,
16665                y: 10.0,
16666                width: 100.0,
16667                height: 50.0,
16668            },
16669            clip: Some(Rect {
16670                x: 0.0,
16671                y: 0.0,
16672                width: 800.0,
16673                height: 600.0,
16674            }),
16675            ..test_shape(0, BlendMode::SrcOver)
16676        });
16677        // Shape far outside viewport — clipped away entirely
16678        scene.shapes.push(DrawShape {
16679            rect: Rect {
16680                x: 0.0,
16681                y: 3000.0,
16682                width: 100.0,
16683                height: 50.0,
16684            },
16685            clip: Some(Rect {
16686                x: 0.0,
16687                y: 0.0,
16688                width: 800.0,
16689                height: 600.0,
16690            }),
16691            ..test_shape(1, BlendMode::SrcOver)
16692        });
16693        let bounds = scene_bounds(&scene).expect("should have bounds");
16694        // Bounds should only cover the first shape's visible area,
16695        // NOT extend to y=3050 from the clipped second shape.
16696        assert!(bounds.y + bounds.height <= 600.0);
16697    }
16698
16699    #[test]
16700    fn scene_bounds_scroll_content_clipped_to_viewport() {
16701        // Simulates a scroll container: many items with large y offsets,
16702        // all clipped to a viewport-sized clip rect.
16703        let mut scene = CompositorScene::new();
16704        let viewport_clip = Rect {
16705            x: 0.0,
16706            y: 0.0,
16707            width: 800.0,
16708            height: 600.0,
16709        };
16710        for i in 0..20 {
16711            scene.shapes.push(DrawShape {
16712                rect: Rect {
16713                    x: 0.0,
16714                    y: i as f32 * 300.0,
16715                    width: 800.0,
16716                    height: 200.0,
16717                },
16718                clip: Some(viewport_clip),
16719                ..test_shape(i, BlendMode::SrcOver)
16720            });
16721        }
16722        let bounds = scene_bounds(&scene).expect("should have bounds");
16723        // All shapes are clipped to viewport — bounds should be viewport-sized,
16724        // NOT 20*300 = 6000 dp tall.
16725        assert_eq!(bounds.x, 0.0);
16726        assert_eq!(bounds.y, 0.0);
16727        assert!(bounds.width <= 800.0);
16728        assert!(bounds.height <= 600.0);
16729    }
16730
16731    #[test]
16732    fn scene_bounds_stable_across_scroll_offsets() {
16733        // Simulates horizontal scroll at different offsets —
16734        // bounds should be identical regardless of scroll position.
16735        let viewport_clip = Rect {
16736            x: 0.0,
16737            y: 0.0,
16738            width: 400.0,
16739            height: 50.0,
16740        };
16741        let compute_bounds_at_offset = |scroll_x: f32| {
16742            let mut scene = CompositorScene::new();
16743            for i in 0..10 {
16744                scene.shapes.push(DrawShape {
16745                    rect: Rect {
16746                        x: i as f32 * 100.0 - scroll_x,
16747                        y: 0.0,
16748                        width: 80.0,
16749                        height: 40.0,
16750                    },
16751                    clip: Some(viewport_clip),
16752                    ..test_shape(i, BlendMode::SrcOver)
16753                });
16754            }
16755            scene_bounds(&scene).expect("bounds")
16756        };
16757        let bounds_at_0 = compute_bounds_at_offset(0.0);
16758        let bounds_at_300 = compute_bounds_at_offset(300.0);
16759        let bounds_at_600 = compute_bounds_at_offset(600.0);
16760        // Width should be stable (clipped to viewport) regardless of scroll offset
16761        assert!(
16762            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
16763            "bounds width changed with scroll: {} vs {}",
16764            bounds_at_0.width,
16765            bounds_at_300.width
16766        );
16767        assert!(
16768            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
16769            "bounds width changed with scroll: {} vs {}",
16770            bounds_at_0.width,
16771            bounds_at_600.width
16772        );
16773    }
16774
16775    #[test]
16776    fn collect_effect_ranges_respects_excluded_effect() {
16777        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
16778        let mut ranges = Vec::new();
16779        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
16780        assert_eq!(ranges.len(), 1);
16781        assert_eq!(ranges[0], 20..30);
16782    }
16783
16784    #[test]
16785    fn collect_layer_events_includes_nested_when_parent_excluded() {
16786        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
16787        let backdrops = vec![backdrop_layer(25)];
16788        let mut events = Vec::new();
16789        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
16790        assert_eq!(events.len(), 2);
16791
16792        match events[0].kind {
16793            LayerEventKind::Effect(index) => assert_eq!(index, 1),
16794            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
16795        }
16796        match events[1].kind {
16797            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
16798            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
16799        }
16800    }
16801
16802    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
16803        LayerNode {
16804            node_id: Some(177),
16805            local_bounds: Rect {
16806                x: 0.0,
16807                y: 0.0,
16808                width: 96.0,
16809                height: 32.0,
16810            },
16811            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
16812            motion_context_animated: animated,
16813            translated_content_context,
16814            translated_content_offset: Point::default(),
16815            content_offset: Point::default(),
16816            scene_children_origin: cranpose_ui_graphics::Point::default(),
16817            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
16818            graphics_layer: GraphicsLayer::default(),
16819            clip_to_bounds: false,
16820            shadow_clip: None,
16821            hit_test: None,
16822            has_hit_targets: false,
16823            isolation: IsolationReasons::default(),
16824            cache_policy: CachePolicy::None,
16825            cache_hashes: LayerRasterCacheHashes::default(),
16826            cache_hashes_valid: false,
16827            children: vec![RenderNode::Primitive(PrimitiveEntry {
16828                phase: PrimitivePhase::BeforeChildren,
16829                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
16830                    node_id: 177,
16831                    rect: Rect {
16832                        x: 0.0,
16833                        y: 0.0,
16834                        width: 96.0,
16835                        height: 24.0,
16836                    },
16837                    clip: None,
16838                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
16839                    text_style: TextStyle::default(),
16840                    font_size: 14.0,
16841                    layout_options: TextLayoutOptions::default(),
16842                })),
16843            })],
16844        }
16845    }
16846
16847    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
16848        let text_leaf = pure_text_leaf(animated, translated_content_context);
16849        test_layer(
16850            Rect {
16851                x: 0.0,
16852                y: 0.0,
16853                width: 160.0,
16854                height: 96.0,
16855            },
16856            vec![RenderNode::Layer(Box::new(text_leaf))],
16857        )
16858    }
16859
16860    #[test]
16861    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
16862        let effects = vec![effect_layer(10, 20)];
16863        let backdrops = vec![backdrop_layer(10)];
16864        let mut events = Vec::new();
16865        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
16866        assert_eq!(events.len(), 2);
16867
16868        match events[0].kind {
16869            LayerEventKind::Backdrop(_) => {}
16870            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
16871        }
16872        match events[1].kind {
16873            LayerEventKind::Effect(_) => {}
16874            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
16875        }
16876    }
16877
16878    #[test]
16879    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
16880        // Child emitted before parent (matching scene collection order where a
16881        // parent effect is recorded after recursively processing children).
16882        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
16883        let mut events = Vec::new();
16884        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
16885
16886        assert_eq!(events.len(), 2);
16887        match events[0].kind {
16888            LayerEventKind::Effect(index) => assert_eq!(index, 1),
16889            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
16890        }
16891        match events[1].kind {
16892            LayerEventKind::Effect(index) => assert_eq!(index, 0),
16893            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
16894        }
16895    }
16896
16897    #[test]
16898    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
16899        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
16900        let mut events = Vec::new();
16901        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
16902
16903        assert_eq!(events.len(), 2);
16904        match events[0].kind {
16905            LayerEventKind::Effect(index) => assert_eq!(index, 1),
16906            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
16907        }
16908        match events[1].kind {
16909            LayerEventKind::Effect(index) => assert_eq!(index, 0),
16910            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
16911        }
16912    }
16913
16914    #[test]
16915    fn has_backdrop_layer_in_range_detects_nested_layers() {
16916        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
16917        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
16918        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
16919        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
16920    }
16921
16922    #[test]
16923    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
16924        let mut self_backdrop = test_layer(
16925            Rect {
16926                x: 0.0,
16927                y: 0.0,
16928                width: 10.0,
16929                height: 10.0,
16930            },
16931            vec![],
16932        );
16933        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
16934        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
16935
16936        let mut child = test_layer(
16937            Rect {
16938                x: 0.0,
16939                y: 0.0,
16940                width: 8.0,
16941                height: 8.0,
16942            },
16943            vec![],
16944        );
16945        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
16946
16947        let parent = test_layer(
16948            Rect {
16949                x: 0.0,
16950                y: 0.0,
16951                width: 20.0,
16952                height: 20.0,
16953            },
16954            vec![RenderNode::Layer(Box::new(child))],
16955        );
16956        assert!(layer_contains_descendant_backdrop(&parent));
16957    }
16958
16959    fn child_layer_composite(
16960        layer: &LayerNode,
16961        z_index: usize,
16962        rect: Rect,
16963        needs_nested_underlay: bool,
16964    ) -> crate::normalized_scene::ChildLayerComposite {
16965        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
16966        let surface_requirements =
16967            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
16968        crate::normalized_scene::ChildLayerComposite {
16969            z_index,
16970            logical_rect: Rect {
16971                x: 0.0,
16972                y: 0.0,
16973                width: rect.width,
16974                height: rect.height,
16975            },
16976            dest_quad: rect_to_quad(rect),
16977            snap_anchor: None,
16978            composite_snap_origin: None,
16979            backdrop_rect: rect,
16980            visual_clip: None,
16981            surface_clip: None,
16982            shadow_draws: Vec::new(),
16983            needs_nested_underlay,
16984            node_id: layer.node_id,
16985            backdrop: layer.backdrop().cloned(),
16986            has_effect: layer.effect().is_some(),
16987            effect_contains_runtime_shader: layer
16988                .effect()
16989                .is_some_and(|effect| effect.contains_runtime_shader()),
16990            target_content_hash: layer.target_content_hash(),
16991            effect_hash: layer.effect_hash(),
16992            motion_source_content_hash: Some(layer.motion_source_content_hash()),
16993            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
16994            cache_policy: layer.cache_policy,
16995            surface_requirements,
16996            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
16997                layer,
16998            ),
16999            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
17000                &layer.graphics_layer,
17001            ),
17002            translated_content_context: layer.translated_content_context,
17003            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
17004                layer,
17005            ),
17006            clip_rect: layer.clip_rect(),
17007            local_bounds: layer.local_bounds,
17008            surface_scale: crate::surface_plan::layer_surface_scale(layer),
17009            source: crate::normalized_scene::LoweredChildSource::default(),
17010        }
17011    }
17012
17013    #[test]
17014    fn root_direct_preflight_allows_first_translated_child_underlay() {
17015        let child = test_layer(
17016            Rect {
17017                x: 0.0,
17018                y: 0.0,
17019                width: 400.0,
17020                height: 280.0,
17021            },
17022            vec![],
17023        );
17024        let collected = CollectedLayer {
17025            scene: CompositorScene::new(),
17026            child_layers: vec![child_layer_composite(
17027                &child,
17028                3,
17029                Rect {
17030                    x: 48.0,
17031                    y: 96.0,
17032                    width: 400.0,
17033                    height: 280.0,
17034                },
17035                true,
17036            )],
17037        };
17038
17039        assert!(direct_root_child_underlays_are_supported(&collected));
17040    }
17041
17042    #[test]
17043    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
17044        let first = test_layer(
17045            Rect {
17046                x: 0.0,
17047                y: 0.0,
17048                width: 80.0,
17049                height: 40.0,
17050            },
17051            vec![],
17052        );
17053        let backdrop_child = test_layer(
17054            Rect {
17055                x: 0.0,
17056                y: 0.0,
17057                width: 400.0,
17058                height: 280.0,
17059            },
17060            vec![],
17061        );
17062        let collected = CollectedLayer {
17063            scene: CompositorScene::new(),
17064            child_layers: vec![
17065                child_layer_composite(
17066                    &first,
17067                    1,
17068                    Rect {
17069                        x: 8.0,
17070                        y: 16.0,
17071                        width: 80.0,
17072                        height: 40.0,
17073                    },
17074                    false,
17075                ),
17076                child_layer_composite(
17077                    &backdrop_child,
17078                    4,
17079                    Rect {
17080                        x: 48.0,
17081                        y: 96.0,
17082                        width: 400.0,
17083                        height: 280.0,
17084                    },
17085                    true,
17086                ),
17087            ],
17088        };
17089
17090        assert!(direct_root_child_underlays_are_supported(&collected));
17091    }
17092
17093    #[test]
17094    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
17095        let mut first = test_layer(
17096            Rect {
17097                x: 0.0,
17098                y: 0.0,
17099                width: 80.0,
17100                height: 40.0,
17101            },
17102            vec![],
17103        );
17104        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
17105        let backdrop_child = test_layer(
17106            Rect {
17107                x: 0.0,
17108                y: 0.0,
17109                width: 400.0,
17110                height: 280.0,
17111            },
17112            vec![],
17113        );
17114        let collected = CollectedLayer {
17115            scene: CompositorScene::new(),
17116            child_layers: vec![
17117                child_layer_composite(
17118                    &first,
17119                    1,
17120                    Rect {
17121                        x: 64.0,
17122                        y: 112.0,
17123                        width: 80.0,
17124                        height: 40.0,
17125                    },
17126                    false,
17127                ),
17128                child_layer_composite(
17129                    &backdrop_child,
17130                    4,
17131                    Rect {
17132                        x: 48.0,
17133                        y: 96.0,
17134                        width: 400.0,
17135                        height: 280.0,
17136                    },
17137                    true,
17138                ),
17139            ],
17140        };
17141
17142        assert!(!direct_root_child_underlays_are_supported(&collected));
17143    }
17144
17145    #[test]
17146    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
17147        let mut first = test_layer(
17148            Rect {
17149                x: 0.0,
17150                y: 0.0,
17151                width: 80.0,
17152                height: 40.0,
17153            },
17154            vec![],
17155        );
17156        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
17157        let backdrop_child = test_layer(
17158            Rect {
17159                x: 0.0,
17160                y: 0.0,
17161                width: 400.0,
17162                height: 280.0,
17163            },
17164            vec![],
17165        );
17166        let collected = CollectedLayer {
17167            scene: CompositorScene::new(),
17168            child_layers: vec![
17169                child_layer_composite(
17170                    &first,
17171                    1,
17172                    Rect {
17173                        x: 8.0,
17174                        y: 16.0,
17175                        width: 80.0,
17176                        height: 40.0,
17177                    },
17178                    false,
17179                ),
17180                child_layer_composite(
17181                    &backdrop_child,
17182                    4,
17183                    Rect {
17184                        x: 48.0,
17185                        y: 96.0,
17186                        width: 400.0,
17187                        height: 280.0,
17188                    },
17189                    true,
17190                ),
17191            ],
17192        };
17193
17194        assert!(direct_root_child_underlays_are_supported(&collected));
17195    }
17196
17197    #[test]
17198    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
17199        let backdrop_child = test_layer(
17200            Rect {
17201                x: 0.0,
17202                y: 0.0,
17203                width: 400.0,
17204                height: 280.0,
17205            },
17206            vec![],
17207        );
17208        let mut scene = CompositorScene::new();
17209        scene.next_z = 1;
17210        scene.push_effect_layer(
17211            Rect {
17212                x: 0.0,
17213                y: 0.0,
17214                width: 120.0,
17215                height: 120.0,
17216            },
17217            None,
17218            Some(RenderEffect::blur(2.0)),
17219            BlendMode::SrcOver,
17220            1.0,
17221            0,
17222            1,
17223        );
17224        let collected = CollectedLayer {
17225            scene,
17226            child_layers: vec![child_layer_composite(
17227                &backdrop_child,
17228                4,
17229                Rect {
17230                    x: 48.0,
17231                    y: 96.0,
17232                    width: 400.0,
17233                    height: 280.0,
17234                },
17235                true,
17236            )],
17237        };
17238
17239        assert!(!direct_root_child_underlays_are_supported(&collected));
17240    }
17241
17242    #[test]
17243    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
17244        let mut backdrop = test_layer(
17245            Rect {
17246                x: 0.0,
17247                y: 0.0,
17248                width: 40.0,
17249                height: 40.0,
17250            },
17251            vec![],
17252        );
17253        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
17254        let child = test_layer(
17255            Rect {
17256                x: 0.0,
17257                y: 0.0,
17258                width: 120.0,
17259                height: 96.0,
17260            },
17261            vec![RenderNode::Layer(Box::new(backdrop))],
17262        );
17263        let root = test_layer(
17264            Rect {
17265                x: 0.0,
17266                y: 0.0,
17267                width: 240.0,
17268                height: 160.0,
17269            },
17270            vec![RenderNode::Layer(Box::new(child))],
17271        );
17272        let mut cache = HashMap::new();
17273
17274        assert!(root_can_render_directly_cached(&root, &mut cache));
17275    }
17276
17277    #[test]
17278    fn root_direct_scene_events_allow_root_local_effects() {
17279        let mut scene = CompositorScene::new();
17280        scene.effect_layers.push(EffectLayer {
17281            rect: Rect {
17282                x: 20.0,
17283                y: 30.0,
17284                width: 120.0,
17285                height: 80.0,
17286            },
17287            clip: None,
17288            snap_anchor: None,
17289            effect: Some(RenderEffect::blur(6.0)),
17290            blend_mode: BlendMode::SrcOver,
17291            composite_alpha: 1.0,
17292            z_start: 0,
17293            z_end: 1,
17294            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
17295        });
17296
17297        assert!(root_direct_scene_events_are_supported(&scene));
17298    }
17299
17300    #[test]
17301    fn root_direct_scene_events_reject_root_local_backdrops() {
17302        let mut scene = CompositorScene::new();
17303        scene.backdrop_layers.push(BackdropLayer {
17304            node_id: Some(99),
17305            rect: Rect {
17306                x: 20.0,
17307                y: 30.0,
17308                width: 120.0,
17309                height: 80.0,
17310            },
17311            clip: None,
17312            snap_anchor: None,
17313            effect: RenderEffect::blur(6.0),
17314            z_index: 1,
17315        });
17316
17317        assert!(!root_direct_scene_events_are_supported(&scene));
17318    }
17319
17320    #[test]
17321    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
17322        let mut child = test_layer(
17323            Rect {
17324                x: 0.0,
17325                y: 0.0,
17326                width: 10.0,
17327                height: 6.0,
17328            },
17329            vec![RenderNode::Primitive(PrimitiveEntry {
17330                phase: PrimitivePhase::BeforeChildren,
17331                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17332                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17333                        rect: Rect {
17334                            x: 0.0,
17335                            y: 0.0,
17336                            width: 10.0,
17337                            height: 6.0,
17338                        },
17339                        brush: Brush::solid(Color::WHITE),
17340                        stroke: None,
17341                    },
17342                    clip: None,
17343                }),
17344            })],
17345        );
17346        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
17347
17348        let parent = test_layer(
17349            Rect {
17350                x: 0.0,
17351                y: 0.0,
17352                width: 4.0,
17353                height: 4.0,
17354            },
17355            vec![RenderNode::Layer(Box::new(child))],
17356        );
17357
17358        assert_eq!(
17359            estimate_layer_surface_rect(&parent),
17360            Rect {
17361                x: 18.0,
17362                y: 7.0,
17363                width: 10.0,
17364                height: 6.0,
17365            }
17366        );
17367    }
17368
17369    #[test]
17370    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
17371        let mut layer = test_layer(
17372            Rect {
17373                x: 0.0,
17374                y: 0.0,
17375                width: 120.0,
17376                height: 72.0,
17377            },
17378            vec![RenderNode::Primitive(PrimitiveEntry {
17379                phase: PrimitivePhase::BeforeChildren,
17380                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17381                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17382                        rect: Rect {
17383                            x: 24.0,
17384                            y: 0.0,
17385                            width: 200.0,
17386                            height: 480.0,
17387                        },
17388                        brush: Brush::solid(Color::WHITE),
17389                        stroke: None,
17390                    },
17391                    clip: None,
17392                }),
17393            })],
17394        );
17395        layer.translated_content_context = true;
17396        layer.motion_context_animated = true;
17397        layer.clip_to_bounds = true;
17398
17399        assert_eq!(
17400            estimate_layer_surface_rect(&layer),
17401            Rect {
17402                x: 24.0,
17403                y: 0.0,
17404                width: 96.0,
17405                height: 72.0,
17406            }
17407        );
17408    }
17409
17410    #[test]
17411    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
17412        let mut layer = test_layer(
17413            Rect {
17414                x: 0.0,
17415                y: 0.0,
17416                width: 120.0,
17417                height: 72.0,
17418            },
17419            vec![RenderNode::Primitive(PrimitiveEntry {
17420                phase: PrimitivePhase::BeforeChildren,
17421                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17422                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17423                        rect: Rect {
17424                            x: -24.0,
17425                            y: 0.0,
17426                            width: 200.0,
17427                            height: 480.0,
17428                        },
17429                        brush: Brush::solid(Color::WHITE),
17430                        stroke: None,
17431                    },
17432                    clip: None,
17433                }),
17434            })],
17435        );
17436        layer.translated_content_context = true;
17437        layer.motion_context_animated = true;
17438        layer.clip_to_bounds = true;
17439
17440        assert_eq!(
17441            estimate_layer_surface_rect(&layer),
17442            Rect {
17443                x: 0.0,
17444                y: 0.0,
17445                width: 120.0,
17446                height: 72.0,
17447            }
17448        );
17449    }
17450
17451    #[test]
17452    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
17453        let mut layer = test_layer(
17454            Rect {
17455                x: 0.0,
17456                y: 0.0,
17457                width: 120.0,
17458                height: 72.0,
17459            },
17460            vec![RenderNode::Primitive(PrimitiveEntry {
17461                phase: PrimitivePhase::BeforeChildren,
17462                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17463                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17464                        rect: Rect {
17465                            x: 0.0,
17466                            y: -24.0,
17467                            width: 120.0,
17468                            height: 200.0,
17469                        },
17470                        brush: Brush::solid(Color::WHITE),
17471                        stroke: None,
17472                    },
17473                    clip: None,
17474                }),
17475            })],
17476        );
17477        layer.translated_content_context = true;
17478        layer.motion_context_animated = true;
17479        layer.clip_to_bounds = true;
17480
17481        assert_eq!(
17482            estimate_layer_surface_rect(&layer),
17483            Rect {
17484                x: 0.0,
17485                y: 0.0,
17486                width: 120.0,
17487                height: 72.0,
17488            }
17489        );
17490    }
17491
17492    #[test]
17493    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
17494        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
17495            let mut layer = test_layer(
17496                Rect {
17497                    x: 0.0,
17498                    y: 0.0,
17499                    width: 120.0,
17500                    height: 72.0,
17501                },
17502                vec![RenderNode::Primitive(PrimitiveEntry {
17503                    phase: PrimitivePhase::BeforeChildren,
17504                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17505                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17506                            rect: Rect {
17507                                x: 0.0,
17508                                y: content_y,
17509                                width: 120.0,
17510                                height: 200.0,
17511                            },
17512                            brush: Brush::solid(Color::WHITE),
17513                            stroke: None,
17514                        },
17515                        clip: None,
17516                    }),
17517                })],
17518            );
17519            layer.translated_content_context = true;
17520            layer.motion_context_animated = true;
17521            layer.clip_to_bounds = true;
17522            estimate_layer_surface_rect(&layer)
17523        }
17524
17525        assert_eq!(
17526            shallow_scroll_surface_rect(-24.0),
17527            shallow_scroll_surface_rect(-25.0),
17528            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
17529        );
17530    }
17531
17532    #[test]
17533    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
17534        let mut layer = test_layer(
17535            Rect {
17536                x: 0.0,
17537                y: 0.0,
17538                width: 120.0,
17539                height: 72.0,
17540            },
17541            vec![RenderNode::Primitive(PrimitiveEntry {
17542                phase: PrimitivePhase::BeforeChildren,
17543                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17544                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17545                        rect: Rect {
17546                            x: -16.0,
17547                            y: -24.0,
17548                            width: 180.0,
17549                            height: 240.0,
17550                        },
17551                        brush: Brush::solid(Color::WHITE),
17552                        stroke: None,
17553                    },
17554                    clip: None,
17555                }),
17556            })],
17557        );
17558        layer.translated_content_context = true;
17559        layer.motion_context_animated = true;
17560        layer.clip_to_bounds = true;
17561
17562        assert_eq!(
17563            estimate_layer_surface_rect(&layer),
17564            Rect {
17565                x: 0.0,
17566                y: 0.0,
17567                width: 120.0,
17568                height: 72.0,
17569            }
17570        );
17571    }
17572
17573    #[test]
17574    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
17575        let mut layer = test_layer(
17576            Rect {
17577                x: 0.0,
17578                y: 0.0,
17579                width: 120.0,
17580                height: 72.0,
17581            },
17582            vec![RenderNode::Primitive(PrimitiveEntry {
17583                phase: PrimitivePhase::BeforeChildren,
17584                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17585                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17586                        rect: Rect {
17587                            x: 0.0,
17588                            y: -1200.0,
17589                            width: 120.0,
17590                            height: 1400.0,
17591                        },
17592                        brush: Brush::solid(Color::WHITE),
17593                        stroke: None,
17594                    },
17595                    clip: None,
17596                }),
17597            })],
17598        );
17599        layer.translated_content_context = true;
17600        layer.motion_context_animated = true;
17601        layer.clip_to_bounds = true;
17602
17603        assert_eq!(
17604            estimate_layer_surface_rect(&layer),
17605            Rect {
17606                x: 0.0,
17607                y: 0.0,
17608                width: 120.0,
17609                height: 72.0,
17610            }
17611        );
17612    }
17613
17614    #[test]
17615    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
17616        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
17617            let mut layer = test_layer(
17618                Rect {
17619                    x: 0.0,
17620                    y: 0.0,
17621                    width: 120.0,
17622                    height: 72.0,
17623                },
17624                vec![RenderNode::Primitive(PrimitiveEntry {
17625                    phase: PrimitivePhase::BeforeChildren,
17626                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17627                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17628                            rect: Rect {
17629                                x: 0.0,
17630                                y: content_y,
17631                                width: 120.0,
17632                                height: 1400.0,
17633                            },
17634                            brush: Brush::solid(Color::WHITE),
17635                            stroke: None,
17636                        },
17637                        clip: None,
17638                    }),
17639                })],
17640            );
17641            layer.translated_content_context = true;
17642            layer.motion_context_animated = true;
17643            layer.clip_to_bounds = true;
17644            estimate_layer_surface_rect(&layer)
17645        }
17646
17647        assert_eq!(
17648            deep_scroll_surface_rect(-1200.0),
17649            deep_scroll_surface_rect(-1201.0),
17650            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
17651        );
17652    }
17653
17654    #[test]
17655    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
17656        let mut layer = test_layer(
17657            Rect {
17658                x: 0.0,
17659                y: 0.0,
17660                width: 120.0,
17661                height: 72.0,
17662            },
17663            vec![],
17664        );
17665        layer.clip_to_bounds = true;
17666        layer.graphics_layer.clip = true;
17667        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
17668
17669        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
17670        shadow_shape.rect = Rect {
17671            x: -24.0,
17672            y: -1200.0,
17673            width: 180.0,
17674            height: 1400.0,
17675        };
17676        let mut scene = CompositorScene::new();
17677        scene
17678            .shadow_draws
17679            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
17680
17681        let requirements = SurfaceRequirementSet::default()
17682            .with(SurfaceRequirement::RenderEffect)
17683            .with(SurfaceRequirement::MotionStableCapture);
17684
17685        assert_eq!(
17686            motion_stable_capture_bounds(
17687                &layer,
17688                &scene,
17689                &[],
17690                requirements,
17691                TranslatedContentAxes::default(),
17692                None,
17693            ),
17694            Some(Rect {
17695                x: -360.0,
17696                y: -216.0,
17697                width: 480.0,
17698                height: 288.0,
17699            })
17700        );
17701    }
17702
17703    #[test]
17704    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
17705        let mut layer = test_layer(
17706            Rect {
17707                x: 0.0,
17708                y: 0.0,
17709                width: 200.0,
17710                height: 100.0,
17711            },
17712            vec![],
17713        );
17714        layer.clip_to_bounds = true;
17715        layer.graphics_layer.clip = true;
17716
17717        let mut shape = test_shape(0, BlendMode::SrcOver);
17718        shape.rect = Rect {
17719            x: 60.0,
17720            y: -80.0,
17721            width: 80.0,
17722            height: 220.0,
17723        };
17724        let mut scene = CompositorScene::new();
17725        scene.shapes.push(shape);
17726
17727        let requirements =
17728            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
17729
17730        assert_eq!(
17731            motion_stable_capture_bounds(
17732                &layer,
17733                &scene,
17734                &[],
17735                requirements,
17736                TranslatedContentAxes { x: false, y: true },
17737                None,
17738            ),
17739            Some(Rect {
17740                x: -96.0,
17741                y: -64.0,
17742                width: 296.0,
17743                height: 164.0,
17744            })
17745        );
17746    }
17747
17748    #[test]
17749    fn vertical_motion_stable_capture_uses_external_surface_clip() {
17750        let layer = test_layer(
17751            Rect {
17752                x: 0.0,
17753                y: 0.0,
17754                width: 200.0,
17755                height: 100.0,
17756            },
17757            vec![],
17758        );
17759
17760        let mut shape = test_shape(0, BlendMode::SrcOver);
17761        shape.rect = Rect {
17762            x: 60.0,
17763            y: -80.0,
17764            width: 80.0,
17765            height: 220.0,
17766        };
17767        let mut scene = CompositorScene::new();
17768        scene.shapes.push(shape);
17769
17770        let requirements =
17771            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
17772
17773        assert_eq!(
17774            motion_stable_capture_bounds(
17775                &layer,
17776                &scene,
17777                &[],
17778                requirements,
17779                TranslatedContentAxes { x: false, y: true },
17780                Some(Rect {
17781                    x: 0.0,
17782                    y: 0.0,
17783                    width: 200.0,
17784                    height: 100.0,
17785                }),
17786            ),
17787            Some(Rect {
17788                x: -96.0,
17789                y: -64.0,
17790                width: 296.0,
17791                height: 164.0,
17792            })
17793        );
17794    }
17795
17796    #[test]
17797    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
17798        let mut child = test_layer(
17799            Rect {
17800                x: 0.0,
17801                y: 0.0,
17802                width: 12.0,
17803                height: 8.0,
17804            },
17805            vec![],
17806        );
17807        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
17808        child.graphics_layer.shadow_elevation = 6.0;
17809
17810        let parent = test_layer(
17811            Rect {
17812                x: 0.0,
17813                y: 0.0,
17814                width: 4.0,
17815                height: 4.0,
17816            },
17817            vec![RenderNode::Layer(Box::new(child))],
17818        );
17819
17820        let rect = estimate_layer_surface_rect(&parent);
17821        assert!(rect.x < 20.0);
17822        assert!(rect.y < 9.0);
17823        assert!(rect.width > 12.0);
17824        assert!(rect.height > 8.0);
17825    }
17826
17827    #[test]
17828    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
17829        let mut layer = test_layer(
17830            Rect {
17831                x: 0.0,
17832                y: 0.0,
17833                width: 28.0,
17834                height: 28.0,
17835            },
17836            vec![RenderNode::Primitive(PrimitiveEntry {
17837                phase: PrimitivePhase::BeforeChildren,
17838                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17839                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17840                        rect: Rect {
17841                            x: 10.0,
17842                            y: 10.0,
17843                            width: 10.0,
17844                            height: 10.0,
17845                        },
17846                        brush: Brush::solid(Color::WHITE),
17847                        stroke: None,
17848                    },
17849                    clip: None,
17850                }),
17851            })],
17852        );
17853        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
17854
17855        assert_eq!(
17856            estimate_layer_surface_rect(&layer),
17857            Rect {
17858                x: 0.0,
17859                y: 0.0,
17860                width: 28.0,
17861                height: 28.0,
17862            }
17863        );
17864    }
17865
17866    #[test]
17867    fn layer_raster_cache_candidate_ignores_parent_transform() {
17868        let primitive = PrimitiveEntry {
17869            phase: PrimitivePhase::BeforeChildren,
17870            node: PrimitiveNode::Draw(DrawPrimitiveNode {
17871                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17872                    rect: Rect {
17873                        x: 2.0,
17874                        y: 3.0,
17875                        width: 6.0,
17876                        height: 4.0,
17877                    },
17878                    brush: Brush::solid(Color::BLACK),
17879                    stroke: None,
17880                },
17881                clip: None,
17882            }),
17883        };
17884        let base = cacheable_layer(
17885            41,
17886            Rect {
17887                x: 0.0,
17888                y: 0.0,
17889                width: 20.0,
17890                height: 20.0,
17891            },
17892            vec![RenderNode::Primitive(primitive.clone())],
17893        );
17894        let mut moved = base.clone();
17895        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
17896
17897        assert_eq!(
17898            layer_raster_cache_candidate(&base, 1.25, false, false),
17899            layer_raster_cache_candidate(&moved, 1.25, false, false)
17900        );
17901    }
17902
17903    #[test]
17904    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
17905        let primitive = PrimitiveEntry {
17906            phase: PrimitivePhase::BeforeChildren,
17907            node: PrimitiveNode::Draw(DrawPrimitiveNode {
17908                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
17909                    rect: Rect {
17910                        x: 2.0,
17911                        y: 3.0,
17912                        width: 6.0,
17913                        height: 4.0,
17914                    },
17915                    brush: Brush::solid(Color::BLACK),
17916                    stroke: None,
17917                },
17918                clip: None,
17919            }),
17920        };
17921        let mut base = cacheable_layer(
17922            42,
17923            Rect {
17924                x: 0.0,
17925                y: 0.0,
17926                width: 20.0,
17927                height: 20.0,
17928            },
17929            vec![RenderNode::Primitive(primitive)],
17930        );
17931        base.translated_content_context = true;
17932        base.translated_content_offset = Point::new(0.0, -8.0);
17933        base.recompute_raster_cache_hashes();
17934
17935        let mut moved = base.clone();
17936        moved.translated_content_offset = Point::new(0.0, -16.0);
17937        moved.recompute_raster_cache_hashes();
17938
17939        assert_ne!(
17940            layer_raster_cache_candidate(&base, 1.25, false, false),
17941            layer_raster_cache_candidate(&moved, 1.25, false, false),
17942            "full-surface layer cache candidates must not alias different scroll offsets"
17943        );
17944    }
17945
17946    #[test]
17947    fn layer_raster_cache_candidate_changes_for_child_transform() {
17948        let mut child = cacheable_layer(
17949            8,
17950            Rect {
17951                x: 0.0,
17952                y: 0.0,
17953                width: 12.0,
17954                height: 10.0,
17955            },
17956            vec![],
17957        );
17958        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
17959        let base = cacheable_layer(
17960            7,
17961            Rect {
17962                x: 0.0,
17963                y: 0.0,
17964                width: 20.0,
17965                height: 20.0,
17966            },
17967            vec![RenderNode::Layer(Box::new(child.clone()))],
17968        );
17969        let mut moved_child = child;
17970        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
17971        let moved = cacheable_layer(
17972            7,
17973            Rect {
17974                x: 0.0,
17975                y: 0.0,
17976                width: 20.0,
17977                height: 20.0,
17978            },
17979            vec![RenderNode::Layer(Box::new(moved_child))],
17980        );
17981
17982        assert_ne!(
17983            layer_raster_cache_candidate(&base, 1.0, false, false),
17984            layer_raster_cache_candidate(&moved, 1.0, false, false)
17985        );
17986    }
17987
17988    #[test]
17989    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
17990        let mut child = cacheable_layer(
17991            12,
17992            Rect {
17993                x: 0.0,
17994                y: 0.0,
17995                width: 8.0,
17996                height: 8.0,
17997            },
17998            vec![],
17999        );
18000        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
18001        let parent = cacheable_layer(
18002            11,
18003            Rect {
18004                x: 0.0,
18005                y: 0.0,
18006                width: 16.0,
18007                height: 16.0,
18008            },
18009            vec![RenderNode::Layer(Box::new(child))],
18010        );
18011
18012        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
18013        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
18014    }
18015
18016    #[test]
18017    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
18018        let text = RenderNode::Primitive(PrimitiveEntry {
18019            phase: PrimitivePhase::BeforeChildren,
18020            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
18021                node_id: 77,
18022                rect: Rect {
18023                    x: 2.0,
18024                    y: 3.0,
18025                    width: 48.0,
18026                    height: 18.0,
18027                },
18028                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
18029                text_style: TextStyle::default(),
18030                font_size: 14.0,
18031                layout_options: TextLayoutOptions::default(),
18032                clip: None,
18033            })),
18034        });
18035        let mut layer = test_layer(
18036            Rect {
18037                x: 0.0,
18038                y: 0.0,
18039                width: 64.0,
18040                height: 32.0,
18041            },
18042            vec![text],
18043        );
18044        layer.node_id = Some(77);
18045        layer.recompute_raster_cache_hashes();
18046
18047        assert!(
18048            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
18049            "root path should not isolate plain translation-only text layers"
18050        );
18051        assert!(
18052            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
18053            "child path should also render plain translation-only text layers directly"
18054        );
18055    }
18056
18057    #[test]
18058    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
18059        let mut layer = test_layer(
18060            Rect {
18061                x: 0.0,
18062                y: 0.0,
18063                width: 64.0,
18064                height: 32.0,
18065            },
18066            vec![RenderNode::Primitive(PrimitiveEntry {
18067                phase: PrimitivePhase::BeforeChildren,
18068                node: PrimitiveNode::Draw(DrawPrimitiveNode {
18069                    primitive: DrawPrimitive::Rect {
18070                        rect: Rect {
18071                            x: 0.0,
18072                            y: 0.0,
18073                            width: 64.0,
18074                            height: 32.0,
18075                        },
18076                        brush: Brush::solid(Color::WHITE),
18077                        stroke: None,
18078                    },
18079                    clip: None,
18080                }),
18081            })],
18082        );
18083        layer.node_id = Some(78);
18084        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
18085        layer.recompute_raster_cache_hashes();
18086
18087        assert!(
18088            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
18089            "root direct path should not force-cache ordinary stable effects"
18090        );
18091        assert!(
18092            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
18093            "child surface rendering should retain stable non-runtime effects"
18094        );
18095    }
18096
18097    #[test]
18098    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
18099        let mut layer = test_layer(
18100            Rect {
18101                x: 0.0,
18102                y: 0.0,
18103                width: 64.0,
18104                height: 32.0,
18105            },
18106            vec![],
18107        );
18108        layer.node_id = Some(79);
18109        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
18110            RuntimeShader::new("runtime shader"),
18111        ));
18112        layer.recompute_raster_cache_hashes();
18113
18114        assert!(
18115            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
18116            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
18117        );
18118    }
18119
18120    #[test]
18121    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
18122        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
18123
18124        let requirements = layer_surface_requirements(&layer);
18125
18126        assert_eq!(requirements.direct_translation, Some(Point::default()));
18127        assert!(requirements
18128            .surface_requirements
18129            .contains(SurfaceRequirement::PixelStableComposite));
18130        assert!(!requirements
18131            .surface_requirements
18132            .has_isolating_requirement());
18133    }
18134
18135    #[test]
18136    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
18137        let layer = pure_text_leaf(false, true);
18138
18139        let requirements = layer_surface_requirements(&layer);
18140
18141        assert_eq!(
18142            requirements.direct_translation,
18143            Some(Point::new(11.4, 23.6))
18144        );
18145        assert!(
18146            requirements
18147                .surface_requirements
18148                .contains(SurfaceRequirement::PixelStableComposite)
18149                && !requirements
18150                    .surface_requirements
18151                    .has_isolating_requirement(),
18152            "translated plain text should stay on the direct path and isolate only the glyph draw"
18153        );
18154    }
18155
18156    #[test]
18157    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
18158        let layer = snapped_text_leaf(false, true);
18159
18160        let requirements = layer_surface_requirements(&layer);
18161
18162        assert_eq!(
18163            requirements.direct_translation,
18164            Some(Point::new(14.25, 16.5))
18165        );
18166        assert!(
18167            requirements
18168                .surface_requirements
18169                .contains(SurfaceRequirement::PixelStableComposite)
18170                && !requirements
18171                    .surface_requirements
18172                    .has_isolating_requirement(),
18173            "translated text with direct sibling decoration/background should keep the layer direct"
18174        );
18175    }
18176
18177    #[test]
18178    fn translated_plain_text_uses_bounded_snap_surface() {
18179        let root = pure_text_leaf_root(true, true);
18180        let mut rect_cache = HashMap::new();
18181        let mut requirements_cache = HashMap::new();
18182        let collected =
18183            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18184
18185        assert_eq!(collected.child_layers.len(), 1);
18186        assert!(collected.scene.texts.is_empty());
18187        assert!(collected.scene.effect_layers.is_empty());
18188        assert_snap_anchor_close(
18189            collected.child_layers[0].snap_anchor,
18190            Point::new(11.4, 23.6),
18191            "translated plain text's bounded local surface should composite at the content-origin snap phase",
18192        );
18193    }
18194
18195    /// Not a correctness test: a local timing harness for the shape-run
18196    /// collect path. Run manually with
18197    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
18198    #[test]
18199    #[ignore]
18200    fn shape_run_collect_timing_harness() {
18201        use cranpose_render_common::graph::DrawPrimitiveNode;
18202        use cranpose_render_common::layer_composition::local_content_layer_for;
18203        use cranpose_ui_graphics::Stroke;
18204
18205        let bounds = Rect {
18206            x: 0.0,
18207            y: 0.0,
18208            width: 1080.0,
18209            height: 2244.0,
18210        };
18211        let graphics_layer = GraphicsLayer::default();
18212
18213        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
18214        // solid, some gradient, one text-free layer.
18215        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
18216        for i in 0..3000u32 {
18217            let f = i as f32;
18218            let brush = if i % 8 == 0 {
18219                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
18220            } else {
18221                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
18222            };
18223            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
18224            let radius = 8.0 + (i % 23) as f32;
18225            let half = radius + 4.0;
18226            nodes.push(DrawPrimitiveNode {
18227                primitive: DrawPrimitive::Arc {
18228                    rect: Rect {
18229                        x: center.x - half,
18230                        y: center.y - half,
18231                        width: half * 2.0,
18232                        height: half * 2.0,
18233                    },
18234                    brush,
18235                    center,
18236                    radius,
18237                    start_angle: f * 0.07,
18238                    sweep_angle: 0.5 + (i % 5) as f32,
18239                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
18240                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
18241                },
18242                clip: None,
18243            });
18244        }
18245
18246        let children: Vec<RenderNode> = nodes
18247            .iter()
18248            .map(|node| {
18249                RenderNode::Primitive(PrimitiveEntry {
18250                    phase: PrimitivePhase::BeforeChildren,
18251                    node: PrimitiveNode::Draw(node.clone()),
18252                })
18253            })
18254            .collect();
18255        let layer = crate::test_support::layer_node(
18256            bounds,
18257            ProjectiveTransform::identity(),
18258            graphics_layer,
18259            children,
18260        );
18261
18262        const ITERS: usize = 300;
18263
18264        // Reference: the pre-run per-primitive path.
18265        let local_layer = local_content_layer_for(&layer.graphics_layer);
18266        let start = Instant::now();
18267        let mut sink_shapes = 0usize;
18268        for _ in 0..ITERS {
18269            let mut scene = CompositorScene::new();
18270            for node in &nodes {
18271                crate::pipeline::push_draw_primitive(
18272                    &node.primitive,
18273                    bounds,
18274                    &local_layer,
18275                    None,
18276                    &mut scene,
18277                    None,
18278                    false,
18279                );
18280            }
18281            sink_shapes = scene.shapes.len();
18282        }
18283        let serial = start.elapsed();
18284
18285        let mut rect_cache = HashMap::new();
18286        let mut requirements_cache = HashMap::new();
18287        let start = Instant::now();
18288        let mut run_shapes = 0usize;
18289        for _ in 0..ITERS {
18290            let collected = collect_layer_contents(
18291                &layer,
18292                None,
18293                None,
18294                &mut rect_cache,
18295                &mut requirements_cache,
18296            );
18297            run_shapes = collected.scene.shapes.len();
18298        }
18299        let run = start.elapsed();
18300
18301        println!(
18302            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
18303            serial / ITERS as u32,
18304            run / ITERS as u32,
18305        );
18306    }
18307
18308    /// Shared body for the serial and forced-parallel equivalence tests:
18309    fn assert_shape_run_collect_matches_per_primitive_emission() {
18310        use cranpose_render_common::graph::DrawPrimitiveNode;
18311        use cranpose_render_common::layer_composition::local_content_layer_for;
18312        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
18313        use cranpose_ui_graphics::{CornerRadii, Stroke};
18314
18315        let bounds = Rect {
18316            x: 0.0,
18317            y: 0.0,
18318            width: 800.0,
18319            height: 800.0,
18320        };
18321        // Rotation keeps rigid snapping off, so both paths agree on
18322        // `snap_anchor: None` without replicating the anchor computation here.
18323        let graphics_layer = GraphicsLayer {
18324            scale: 1.25,
18325            translation_x: 3.5,
18326            translation_y: -2.0,
18327            alpha: 0.9,
18328            rotation_z: 0.35,
18329            ..GraphicsLayer::default()
18330        };
18331
18332        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
18333        for i in 0..600u32 {
18334            let f = i as f32;
18335            let brush = if i % 11 == 0 {
18336                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
18337            } else {
18338                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
18339            };
18340            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
18341            let primitive = match i % 3 {
18342                0 => DrawPrimitive::Rect {
18343                    rect: Rect {
18344                        x: f % 37.0,
18345                        y: f % 53.0,
18346                        width: 8.0 + f % 9.0,
18347                        height: 6.0 + f % 5.0,
18348                    },
18349                    brush,
18350                    stroke,
18351                },
18352                1 => DrawPrimitive::RoundRect {
18353                    rect: Rect {
18354                        x: f % 41.0,
18355                        y: f % 43.0,
18356                        width: 12.0,
18357                        height: 10.0,
18358                    },
18359                    brush,
18360                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
18361                    stroke,
18362                },
18363                _ => {
18364                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
18365                    let radius = 5.0 + (i % 13) as f32;
18366                    // One degenerate sweep proves dropped draws stay dropped.
18367                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
18368                    let half = radius + 4.0;
18369                    DrawPrimitive::Arc {
18370                        rect: Rect {
18371                            x: center.x - half,
18372                            y: center.y - half,
18373                            width: half * 2.0,
18374                            height: half * 2.0,
18375                        },
18376                        brush,
18377                        center,
18378                        radius,
18379                        start_angle: f * 0.11,
18380                        sweep_angle,
18381                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
18382                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
18383                    }
18384                }
18385            };
18386            let primitive = if i == 300 {
18387                // A nested blend disqualifies the run view and forces a
18388                // mid-run flush through the serial path, splitting 600 draws
18389                // into two runs that are both long enough to fan out.
18390                DrawPrimitive::Blend {
18391                    primitive: Box::new(DrawPrimitive::Blend {
18392                        primitive: Box::new(primitive),
18393                        blend_mode: BlendMode::SrcOver,
18394                    }),
18395                    blend_mode: BlendMode::DstOut,
18396                }
18397            } else if i % 7 == 3 {
18398                DrawPrimitive::Blend {
18399                    primitive: Box::new(primitive),
18400                    blend_mode: BlendMode::DstOut,
18401                }
18402            } else {
18403                primitive
18404            };
18405            let clip = (i % 31 == 7).then_some(Rect {
18406                x: 0.0,
18407                y: 0.0,
18408                width: 30.0,
18409                height: 30.0,
18410            });
18411            nodes.push(DrawPrimitiveNode { primitive, clip });
18412        }
18413
18414        let children: Vec<RenderNode> = nodes
18415            .iter()
18416            .map(|node| {
18417                RenderNode::Primitive(PrimitiveEntry {
18418                    phase: PrimitivePhase::BeforeChildren,
18419                    node: PrimitiveNode::Draw(node.clone()),
18420                })
18421            })
18422            .collect();
18423        let layer = crate::test_support::layer_node(
18424            bounds,
18425            ProjectiveTransform::identity(),
18426            graphics_layer,
18427            children,
18428        );
18429
18430        let mut rect_cache = HashMap::new();
18431        let mut requirements_cache = HashMap::new();
18432        let collected =
18433            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
18434
18435        // The reference scene: every primitive through the per-primitive
18436        // emission path, exactly as the pre-run collect loop ran it.
18437        let local_layer = local_content_layer_for(&layer.graphics_layer);
18438        let mut expected = CompositorScene::new();
18439        for node in &nodes {
18440            let clip = resolve_primitive_clip(
18441                node.clip,
18442                bounds,
18443                &local_layer,
18444                None,
18445                PrimitiveClipSpace::Local,
18446            );
18447            if node.clip.is_some() && clip.is_none() {
18448                continue;
18449            }
18450            crate::pipeline::push_draw_primitive(
18451                &node.primitive,
18452                bounds,
18453                &local_layer,
18454                clip,
18455                &mut expected,
18456                None,
18457                false,
18458            );
18459        }
18460
18461        assert!(
18462            collected.scene.shapes.len() >= 590,
18463            "the runs should engage the parallel branch: got {} shapes",
18464            collected.scene.shapes.len()
18465        );
18466        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
18467        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
18468        assert_eq!(collected.scene.next_z, expected.next_z);
18469        assert!(
18470            collected
18471                .scene
18472                .shapes
18473                .iter()
18474                .all(|s| s.snap_anchor.is_none()),
18475            "a rotated layer must not rigid-snap; the reference scene assumes it"
18476        );
18477        for (index, (got, want)) in collected
18478            .scene
18479            .shapes
18480            .iter()
18481            .zip(&expected.shapes)
18482            .enumerate()
18483        {
18484            assert_eq!(got.rect, want.rect, "shape {index} rect");
18485            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
18486            assert_eq!(got.quad, want.quad, "shape {index} quad");
18487            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
18488            assert_eq!(got.brush, want.brush, "shape {index} brush");
18489            assert_eq!(got.shape, want.shape, "shape {index} shape");
18490            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
18491            assert_eq!(got.arc, want.arc, "shape {index} arc");
18492            assert_eq!(got.z_index, want.z_index, "shape {index} z");
18493            assert_eq!(got.clip, want.clip, "shape {index} clip");
18494            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
18495            assert_eq!(
18496                got.motion_context_animated, want.motion_context_animated,
18497                "shape {index} motion flag"
18498            );
18499        }
18500    }
18501
18502    /// The run collector must emit exactly what per-primitive emission does,
18503    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
18504    /// (forced via the tuning override, since a test-sized scene would never
18505    /// cross the size gate on its own).
18506    #[test]
18507    fn shape_run_collect_matches_per_primitive_emission_exactly() {
18508        assert_shape_run_collect_matches_per_primitive_emission();
18509        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
18510        let outcome =
18511            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
18512        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
18513        if let Err(payload) = outcome {
18514            std::panic::resume_unwind(payload);
18515        }
18516    }
18517
18518    #[test]
18519    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
18520        let layer = text_layer_with_style(
18521            AnnotatedString::from("gradient"),
18522            TextStyle::from_span_style(SpanStyle {
18523                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
18524                ..SpanStyle::default()
18525            }),
18526        );
18527        let requirements = layer_surface_requirements(&layer);
18528
18529        assert!(requirements
18530            .surface_requirements
18531            .contains(SurfaceRequirement::TextMaterialMask));
18532        assert_eq!(
18533            composite_sample_mode_for_requirements(false, false, requirements),
18534            CompositeSampleMode::Linear
18535        );
18536    }
18537
18538    #[test]
18539    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
18540        let layer = text_layer_with_style(
18541            AnnotatedString::from("shadow"),
18542            TextStyle::from_span_style(SpanStyle {
18543                shadow: Some(Shadow {
18544                    color: Color::BLACK,
18545                    offset: Point::new(1.0, 2.0),
18546                    blur_radius: 3.0,
18547                }),
18548                ..SpanStyle::default()
18549            }),
18550        );
18551        let requirements = layer_surface_requirements(&layer);
18552
18553        assert!(requirements
18554            .surface_requirements
18555            .contains(SurfaceRequirement::TextMaterialMask));
18556        assert_eq!(
18557            composite_sample_mode_for_requirements(true, false, requirements),
18558            CompositeSampleMode::Box4
18559        );
18560        assert_eq!(
18561            layer_surface_target_scale(
18562                true,
18563                false,
18564                requirements,
18565                1.25,
18566                layer_surface_scale(&layer)
18567            ),
18568            SurfaceRequirementSet::default()
18569                .with(SurfaceRequirement::TextMaterialMask)
18570                .with(SurfaceRequirement::MotionStableCapture)
18571                .target_scale(1.25, 1.0)
18572        );
18573    }
18574
18575    #[test]
18576    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
18577        let layer = text_layer_with_style(
18578            AnnotatedString::from("shadow"),
18579            TextStyle::from_span_style(SpanStyle {
18580                shadow: Some(Shadow {
18581                    color: Color::BLACK,
18582                    offset: Point::new(1.0, 2.0),
18583                    blur_radius: 3.0,
18584                }),
18585                ..SpanStyle::default()
18586            }),
18587        );
18588        let requirements = layer_surface_requirements(&layer);
18589
18590        assert_eq!(
18591            composite_sample_mode_for_requirements(true, true, requirements),
18592            CompositeSampleMode::Linear
18593        );
18594        assert_eq!(
18595            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
18596            SurfaceRequirementSet::default()
18597                .with(SurfaceRequirement::TextMaterialMask)
18598                .target_scale(10.0, 1.0)
18599        );
18600    }
18601
18602    #[test]
18603    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
18604        let cases = [
18605            (
18606                "draw_style",
18607                AnnotatedString::from("draw_style"),
18608                TextStyle::from_span_style(SpanStyle {
18609                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
18610                    ..SpanStyle::default()
18611                }),
18612            ),
18613            (
18614                "gradient_brush",
18615                AnnotatedString::from("gradient"),
18616                TextStyle::from_span_style(SpanStyle {
18617                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
18618                    ..SpanStyle::default()
18619                }),
18620            ),
18621        ];
18622
18623        for (label, text, text_style) in cases {
18624            let layer = text_layer_with_style(text, text_style);
18625            let requirements = layer_surface_requirements(&layer);
18626            assert!(
18627                requirements
18628                    .surface_requirements
18629                    .contains(SurfaceRequirement::TextMaterialMask),
18630                "{label} text should use a bounded local surface: {requirements:?}"
18631            );
18632        }
18633    }
18634
18635    #[test]
18636    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
18637        let cases = [
18638            (
18639                "shadow",
18640                AnnotatedString::from("shadow"),
18641                TextStyle::from_span_style(SpanStyle {
18642                    shadow: Some(Shadow {
18643                        color: Color::BLACK,
18644                        offset: Point::new(1.0, 2.0),
18645                        blur_radius: 3.0,
18646                    }),
18647                    ..SpanStyle::default()
18648                }),
18649            ),
18650            (
18651                "background",
18652                AnnotatedString::from("background"),
18653                TextStyle::from_span_style(SpanStyle {
18654                    background: Some(Color::BLACK),
18655                    ..SpanStyle::default()
18656                }),
18657            ),
18658            (
18659                "baseline_shift",
18660                AnnotatedString::from("baseline_shift"),
18661                TextStyle::from_span_style(SpanStyle {
18662                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
18663                    ..SpanStyle::default()
18664                }),
18665            ),
18666            (
18667                "geometric_transform",
18668                AnnotatedString::from("geometric_transform"),
18669                TextStyle::from_span_style(SpanStyle {
18670                    text_geometric_transform: Some(TextGeometricTransform {
18671                        scale_x: 1.2,
18672                        skew_x: 0.15,
18673                    }),
18674                    ..SpanStyle::default()
18675                }),
18676            ),
18677            (
18678                "letter_spacing",
18679                AnnotatedString::from("letter_spacing"),
18680                TextStyle::from_span_style(SpanStyle {
18681                    letter_spacing: TextUnit::Em(0.2),
18682                    ..SpanStyle::default()
18683                }),
18684            ),
18685        ];
18686
18687        for (label, text, text_style) in cases {
18688            let layer = text_layer_with_style(text, text_style);
18689            let requirements = layer_surface_requirements(&layer);
18690            assert!(
18691                requirements
18692                    .surface_requirements
18693                    .contains(SurfaceRequirement::TextMaterialMask),
18694                "{label} text should use a bounded local surface: {requirements:?}"
18695            );
18696            assert_eq!(
18697                requirements.direct_translation,
18698                Some(Point::default()),
18699                "{label} text should still classify as a direct translation"
18700            );
18701        }
18702    }
18703
18704    #[test]
18705    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
18706        let layer = text_layer_with_style(
18707            AnnotatedString {
18708                text: "styled".to_string(),
18709                span_styles: vec![RangeStyle {
18710                    item: SpanStyle {
18711                        color: Some(Color::BLACK),
18712                        ..SpanStyle::default()
18713                    },
18714                    range: 0..3,
18715                }],
18716                ..AnnotatedString::default()
18717            },
18718            TextStyle::default(),
18719        );
18720        let requirements = layer_surface_requirements(&layer);
18721        assert!(
18722            !requirements
18723                .surface_requirements
18724                .contains(SurfaceRequirement::TextMaterialMask),
18725            "color-only span styles should render directly via software text raster colors"
18726        );
18727    }
18728
18729    #[test]
18730    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
18731        let layer = text_layer_with_style(
18732            AnnotatedString::from("decoration"),
18733            TextStyle::from_span_style(SpanStyle {
18734                text_decoration: Some(TextDecoration::UNDERLINE),
18735                ..SpanStyle::default()
18736            }),
18737        );
18738
18739        let requirements = layer_surface_requirements(&layer);
18740
18741        assert_eq!(requirements.direct_translation, Some(Point::default()));
18742        assert!(
18743            requirements
18744                .surface_requirements
18745                .contains(SurfaceRequirement::PixelStableComposite)
18746                && !requirements
18747                    .surface_requirements
18748                    .has_isolating_requirement(),
18749            "decoration-only text should not force an isolating layer surface: {requirements:?}"
18750        );
18751    }
18752
18753    #[test]
18754    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
18755        let root = snapped_text_leaf_root(false, false);
18756        let mut rect_cache = HashMap::new();
18757        let mut requirements_cache = HashMap::new();
18758
18759        let collected =
18760            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18761
18762        assert_eq!(collected.scene.shapes.len(), 1);
18763        assert_eq!(collected.scene.images.len(), 1);
18764        assert_eq!(collected.scene.texts.len(), 1);
18765        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
18766        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
18767        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
18768        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
18769    }
18770
18771    #[test]
18772    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
18773        let root = snapped_text_leaf_root(true, true);
18774        let mut rect_cache = HashMap::new();
18775        let mut requirements_cache = HashMap::new();
18776
18777        let collected =
18778            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18779
18780        assert_eq!(collected.child_layers.len(), 1);
18781        assert!(collected.scene.shapes.is_empty());
18782        assert!(collected.scene.images.is_empty());
18783        assert!(collected.scene.texts.is_empty());
18784        assert!(collected.scene.effect_layers.is_empty());
18785        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
18786        assert_eq!(
18787            collected.child_layers[0].snap_anchor, expected_anchor,
18788            "active translated leaf surface should keep the content-origin snap phase"
18789        );
18790    }
18791
18792    #[test]
18793    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
18794        let mut child = snapped_text_leaf(false, false);
18795        child.graphics_layer.rotation_z = 5.0;
18796        child.transform_to_parent =
18797            cranpose_render_common::layer_transform::layer_transform_to_parent(
18798                child.local_bounds,
18799                Point::new(108.0, 3.0),
18800                &child.graphics_layer,
18801            );
18802        child.recompute_raster_cache_hashes();
18803        let mut root = test_layer(
18804            Rect {
18805                x: 0.0,
18806                y: 0.0,
18807                width: 320.0,
18808                height: 180.0,
18809            },
18810            vec![RenderNode::Layer(Box::new(child))],
18811        );
18812        root.translated_content_context = true;
18813        root.translated_content_offset = Point::new(0.0, -80.8);
18814        root.recompute_raster_cache_hashes();
18815        let mut rect_cache = HashMap::new();
18816        let mut requirements_cache = HashMap::new();
18817
18818        let collected =
18819            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18820
18821        assert_eq!(collected.child_layers.len(), 1);
18822        assert!(
18823            collected.child_layers[0].snap_anchor.is_some(),
18824            "a projective child still translates rigidly with its scrolling parent"
18825        );
18826    }
18827
18828    #[test]
18829    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
18830        let root = snapped_text_leaf_root(false, true);
18831        let mut rect_cache = HashMap::new();
18832        let mut requirements_cache = HashMap::new();
18833
18834        let collected =
18835            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18836
18837        assert_eq!(collected.child_layers.len(), 0);
18838        assert_eq!(collected.scene.shapes.len(), 1);
18839        assert_eq!(collected.scene.images.len(), 1);
18840        assert_eq!(collected.scene.texts.len(), 1);
18841        assert_eq!(collected.scene.effect_layers.len(), 0);
18842        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
18843        assert_eq!(
18844            collected.scene.shapes[0].snap_anchor, expected_anchor,
18845            "rested scroll content should snap back to device pixels"
18846        );
18847        assert_eq!(
18848            collected.scene.images[0].snap_anchor, expected_anchor,
18849            "rested scroll images should snap back to device pixels"
18850        );
18851        assert_eq!(
18852            collected.scene.texts[0].snap_anchor, expected_anchor,
18853            "rested scroll text should snap back to device pixels"
18854        );
18855    }
18856
18857    #[test]
18858    fn complex_text_uses_local_surface() {
18859        let root = translated_content_local_surface_root();
18860        let mut rect_cache = HashMap::new();
18861        let mut requirements_cache = HashMap::new();
18862
18863        let collected =
18864            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18865
18866        assert!(
18867            !collected.child_layers.is_empty(),
18868            "translated-content effectful text should render through a bounded local surface"
18869        );
18870        assert!(collected.scene.texts.is_empty());
18871        assert!(collected.scene.shadow_draws.is_empty());
18872    }
18873
18874    #[test]
18875    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
18876        let mut root = translated_content_local_surface_root();
18877        let scroll_offset = Point::new(0.0, -18.5);
18878        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
18879            panic!("expected translated content layer");
18880        };
18881        translated_content.translated_content_offset = scroll_offset;
18882        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
18883            panic!("expected effectful text layer");
18884        };
18885        effectful_text.transform_to_parent =
18886            effectful_text
18887                .transform_to_parent
18888                .then(ProjectiveTransform::translation(
18889                    scroll_offset.x,
18890                    scroll_offset.y,
18891                ));
18892
18893        let mut rect_cache = HashMap::new();
18894        let mut requirements_cache = HashMap::new();
18895        let collected =
18896            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18897
18898        assert_eq!(collected.child_layers.len(), 1);
18899        assert_eq!(
18900            collected.child_layers[0].snap_anchor,
18901            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
18902            "isolated scrolled descendants must composite with the same content-origin snap phase"
18903        );
18904    }
18905
18906    #[test]
18907    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
18908        let mut root = translated_content_local_surface_root();
18909        let scroll_offset = Point::new(0.0, -18.5);
18910        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
18911            panic!("expected translated content layer");
18912        };
18913        translated_content.motion_context_animated = true;
18914        translated_content.translated_content_offset = scroll_offset;
18915        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
18916            panic!("expected effectful text layer");
18917        };
18918        effectful_text.transform_to_parent =
18919            effectful_text
18920                .transform_to_parent
18921                .then(ProjectiveTransform::translation(
18922                    scroll_offset.x,
18923                    scroll_offset.y,
18924                ));
18925
18926        let mut rect_cache = HashMap::new();
18927        let mut requirements_cache = HashMap::new();
18928        let collected =
18929            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
18930
18931        assert_eq!(collected.child_layers.len(), 1);
18932        assert_eq!(
18933            collected.child_layers[0].snap_anchor,
18934            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
18935            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
18936        );
18937    }
18938
18939    #[test]
18940    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
18941        let mut layer = text_layer_with_style(
18942            AnnotatedString::from("gradient"),
18943            TextStyle::from_span_style(SpanStyle {
18944                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
18945                ..SpanStyle::default()
18946            }),
18947        );
18948        layer.translated_content_context = true;
18949        layer.translated_content_offset = Point::new(0.0, -18.5);
18950        let mut rect_cache = HashMap::new();
18951        let mut requirements_cache = HashMap::new();
18952
18953        let collected =
18954            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
18955
18956        assert_eq!(collected.scene.effect_layers.len(), 1);
18957        assert_eq!(
18958            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
18959            CompositeSampleMode::Box4
18960        );
18961        assert_eq!(
18962            collected.scene.effect_layers[0].snap_anchor,
18963            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
18964            "text material surfaces must composite with the scroll content-origin snap phase"
18965        );
18966    }
18967
18968    #[test]
18969    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
18970        let mut layer = text_layer_with_style(
18971            AnnotatedString::from("shadow"),
18972            TextStyle::from_span_style(SpanStyle {
18973                shadow: Some(Shadow {
18974                    color: Color::BLACK,
18975                    offset: Point::new(1.0, 2.0),
18976                    blur_radius: 3.0,
18977                }),
18978                ..SpanStyle::default()
18979            }),
18980        );
18981        layer.translated_content_context = true;
18982        let mut rect_cache = HashMap::new();
18983        let mut requirements_cache = HashMap::new();
18984
18985        let collected = collect_layer_contents_with_translation_context(
18986            &layer,
18987            None,
18988            None,
18989            TranslationRenderContext {
18990                inherited_content_translation: false,
18991                surface_capture_active: true,
18992                local_picture_capture_active: true,
18993                ..TranslationRenderContext::default()
18994            },
18995            &mut rect_cache,
18996            &mut requirements_cache,
18997        );
18998
18999        assert!(
19000            collected.scene.effect_layers.is_empty(),
19001            "a translated layer surface already provides the stable local capture"
19002        );
19003        assert_eq!(collected.scene.shadow_draws.len(), 1);
19004        assert_eq!(collected.scene.texts.len(), 1);
19005        assert!(
19006            !collected.scene.texts[0].translated_content_context,
19007            "text inside an active motion-stable capture must raster in capture-local coordinates"
19008        );
19009    }
19010
19011    #[test]
19012    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
19013        let mut layer = text_layer_with_style(
19014            AnnotatedString::from("gradient"),
19015            TextStyle::from_span_style(SpanStyle {
19016                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
19017                ..SpanStyle::default()
19018            }),
19019        );
19020        layer.translated_content_context = true;
19021        let mut rect_cache = HashMap::new();
19022        let mut requirements_cache = HashMap::new();
19023
19024        let collected = collect_layer_contents_with_translation_context(
19025            &layer,
19026            None,
19027            None,
19028            TranslationRenderContext {
19029                inherited_content_translation: false,
19030                surface_capture_active: true,
19031                local_picture_capture_active: true,
19032                ..TranslationRenderContext::default()
19033            },
19034            &mut rect_cache,
19035            &mut requirements_cache,
19036        );
19037
19038        assert_eq!(collected.scene.effect_layers.len(), 1);
19039        assert!(
19040            collected.scene.effect_layers[0]
19041                .requirements
19042                .contains(SurfaceRequirement::MotionStableCapture),
19043            "translated text materials still need motion-stable resolve semantics inside a stable capture"
19044        );
19045        assert_eq!(
19046            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
19047            CompositeSampleMode::Box4
19048        );
19049        assert_eq!(
19050            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
19051            10.0
19052        );
19053        assert!(collected.scene.effect_layers[0].effect.is_some());
19054    }
19055
19056    #[test]
19057    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
19058        let mut layer = text_layer_with_style(
19059            AnnotatedString::from("shadow"),
19060            TextStyle::from_span_style(SpanStyle {
19061                shadow: Some(Shadow {
19062                    color: Color::BLACK,
19063                    offset: Point::new(1.0, 2.0),
19064                    blur_radius: 3.0,
19065                }),
19066                ..SpanStyle::default()
19067            }),
19068        );
19069        layer.translated_content_context = true;
19070        layer.motion_context_animated = true;
19071        let mut rect_cache = HashMap::new();
19072        let mut requirements_cache = HashMap::new();
19073
19074        let collected = collect_layer_contents_with_translation_context(
19075            &layer,
19076            None,
19077            None,
19078            TranslationRenderContext {
19079                surface_capture_active: true,
19080                ..TranslationRenderContext::default()
19081            },
19082            &mut rect_cache,
19083            &mut requirements_cache,
19084        );
19085
19086        assert_eq!(
19087            collected.scene.effect_layers.len(),
19088            0,
19089            "plain translated content inside a viewport surface should not be captured again"
19090        );
19091        assert_eq!(collected.scene.shadow_draws.len(), 1);
19092        assert_eq!(collected.scene.texts.len(), 1);
19093    }
19094
19095    #[test]
19096    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
19097        let root = pure_text_leaf_root(false, false);
19098        let mut rect_cache = HashMap::new();
19099        let mut requirements_cache = HashMap::new();
19100
19101        let collected =
19102            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19103
19104        assert_eq!(collected.scene.texts.len(), 1);
19105        assert!(
19106            collected.scene.texts[0].snap_anchor.is_some(),
19107            "idle pure text leaves should participate in rigid snap anchoring"
19108        );
19109    }
19110
19111    #[test]
19112    fn animated_pure_text_leaf_stays_unsnapped() {
19113        let root = pure_text_leaf_root(true, false);
19114        let mut rect_cache = HashMap::new();
19115        let mut requirements_cache = HashMap::new();
19116
19117        let collected =
19118            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19119
19120        assert_eq!(collected.scene.texts.len(), 1);
19121        assert_eq!(collected.scene.texts[0].snap_anchor, None);
19122    }
19123
19124    #[test]
19125    fn animated_translated_pure_text_uses_bounded_content_snap() {
19126        let root = pure_text_leaf_root(true, true);
19127        let mut rect_cache = HashMap::new();
19128        let mut requirements_cache = HashMap::new();
19129
19130        let collected =
19131            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19132
19133        assert_eq!(collected.child_layers.len(), 1);
19134        assert!(collected.scene.texts.is_empty());
19135        assert!(collected.scene.effect_layers.is_empty());
19136        assert_snap_anchor_close(
19137            collected.child_layers[0].snap_anchor,
19138            Point::new(11.4, 23.6),
19139            "animated translated pure text should use the bounded content snap phase",
19140        );
19141    }
19142
19143    #[test]
19144    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
19145        let root = pure_text_leaf_root(false, true);
19146        let mut rect_cache = HashMap::new();
19147        let mut requirements_cache = HashMap::new();
19148
19149        let collected =
19150            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19151
19152        assert_eq!(collected.child_layers.len(), 0);
19153        assert_eq!(collected.scene.texts.len(), 1);
19154        assert_eq!(collected.scene.effect_layers.len(), 0);
19155        assert_snap_anchor_close(
19156            collected.scene.texts[0].snap_anchor,
19157            Point::new(11.4, 23.6),
19158            "rested translated text should snap to device pixels",
19159        );
19160    }
19161
19162    #[test]
19163    fn static_gpu_effect_text_leaf_stays_unsnapped() {
19164        let root = text_layer_with_style(
19165            AnnotatedString::from("Gradient"),
19166            TextStyle::from_span_style(SpanStyle {
19167                brush: Some(Brush::linear_gradient(vec![
19168                    Color(0.2, 0.8, 1.0, 1.0),
19169                    Color(1.0, 0.7, 0.4, 1.0),
19170                ])),
19171                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
19172                ..SpanStyle::default()
19173            }),
19174        );
19175        let mut rect_cache = HashMap::new();
19176        let mut requirements_cache = HashMap::new();
19177
19178        let collected =
19179            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19180
19181        assert_eq!(collected.scene.texts.len(), 1);
19182        assert_eq!(
19183            collected.scene.texts[0].snap_anchor, None,
19184            "gpu text-effect leaves must not take the rigid text snap path"
19185        );
19186        assert_eq!(
19187            collected.scene.effect_layers.len(),
19188            1,
19189            "gradient stroke text should still emit a runtime shader effect layer"
19190        );
19191    }
19192
19193    #[test]
19194    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
19195        let mut child = test_layer(
19196            Rect {
19197                x: 0.0,
19198                y: 0.0,
19199                width: 40.0,
19200                height: 20.0,
19201            },
19202            vec![RenderNode::Primitive(PrimitiveEntry {
19203                phase: PrimitivePhase::BeforeChildren,
19204                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19205                    primitive: DrawPrimitive::Rect {
19206                        rect: Rect {
19207                            x: 0.0,
19208                            y: 0.0,
19209                            width: 40.0,
19210                            height: 20.0,
19211                        },
19212                        brush: Brush::solid(Color::WHITE),
19213                        stroke: None,
19214                    },
19215                    clip: None,
19216                }),
19217            })],
19218        );
19219        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
19220
19221        let layer = test_layer(
19222            Rect {
19223                x: 0.0,
19224                y: 0.0,
19225                width: 64.0,
19226                height: 32.0,
19227            },
19228            vec![
19229                RenderNode::Primitive(PrimitiveEntry {
19230                    phase: PrimitivePhase::BeforeChildren,
19231                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19232                        primitive: DrawPrimitive::Rect {
19233                            rect: Rect {
19234                                x: 0.0,
19235                                y: 0.0,
19236                                width: 64.0,
19237                                height: 32.0,
19238                            },
19239                            brush: Brush::solid(Color::BLACK),
19240                            stroke: None,
19241                        },
19242                        clip: None,
19243                    }),
19244                }),
19245                RenderNode::Layer(Box::new(child)),
19246            ],
19247        );
19248
19249        let requirements = layer_surface_requirements(&layer);
19250
19251        assert_eq!(requirements.direct_translation, Some(Point::default()));
19252        assert!(!requirements
19253            .surface_requirements
19254            .contains(SurfaceRequirement::MixedDirectContent));
19255        assert!(!requirements
19256            .surface_requirements
19257            .has_isolating_requirement());
19258    }
19259
19260    #[test]
19261    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
19262        let mut child = text_layer_with_style(
19263            AnnotatedString::from("direct"),
19264            TextStyle::from_span_style(SpanStyle {
19265                text_decoration: Some(TextDecoration::UNDERLINE),
19266                ..SpanStyle::default()
19267            }),
19268        );
19269        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
19270
19271        let parent = test_layer(
19272            Rect {
19273                x: 0.0,
19274                y: 0.0,
19275                width: 64.0,
19276                height: 32.0,
19277            },
19278            vec![RenderNode::Layer(Box::new(child))],
19279        );
19280
19281        let mut rect_cache = HashMap::new();
19282        let mut requirements_cache = HashMap::new();
19283        let collected = with_test_app_context(|| {
19284            collect_layer_contents(
19285                &parent,
19286                None,
19287                None,
19288                &mut rect_cache,
19289                &mut requirements_cache,
19290            )
19291        });
19292
19293        assert!(
19294            collected.child_layers.is_empty(),
19295            "decoration-only text child should collapse directly into the parent scene"
19296        );
19297        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
19298        let text = &collected.scene.texts[0];
19299        assert!(
19300            text.rect.x >= 9.0 && text.rect.y >= 7.0,
19301            "collapsed text rect should be translated into parent space, got {:?}",
19302            text.rect
19303        );
19304        assert!(
19305            collected
19306                .scene
19307                .shapes
19308                .iter()
19309                .any(|shape| shape.rect.y >= 7.0),
19310            "collapsed underline geometry should also be translated into parent space"
19311        );
19312    }
19313
19314    #[test]
19315    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
19316        use std::cell::RefCell;
19317
19318        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
19319            for child in &layer.children {
19320                match child {
19321                    RenderNode::Primitive(PrimitiveEntry {
19322                        node: PrimitiveNode::Text(text),
19323                        ..
19324                    }) => labels.push(text.text.text.clone()),
19325                    RenderNode::Layer(child_layer) => {
19326                        collect_graph_text_labels(child_layer, labels)
19327                    }
19328                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
19329                }
19330            }
19331        }
19332
19333        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
19334        let state_holder_for_comp = state_holder.clone();
19335        let mut composition = cranpose_ui::run_test_composition(move || {
19336            let list_state = remember_lazy_list_state();
19337            *state_holder_for_comp.borrow_mut() = Some(list_state);
19338            let mut spec = LazyColumnSpec::new()
19339                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
19340            spec.beyond_bounds_item_count = 0;
19341            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
19342                scope.items(
19343                    12,
19344                    None::<fn(usize) -> u64>,
19345                    None::<fn(usize) -> u64>,
19346                    |index| {
19347                        Text(
19348                            format!("WarmRow {index}"),
19349                            Modifier::empty().height(32.0),
19350                            TextStyle::default(),
19351                        );
19352                    },
19353                );
19354            });
19355        });
19356
19357        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
19358        list_state.scroll_to_item(4, 0.0);
19359
19360        let root = composition.root().expect("lazy column root");
19361        let handle = composition.runtime_handle();
19362        let mut applier = composition.applier_mut();
19363        applier.set_runtime_handle(handle);
19364        let _ = applier
19365            .compute_layout(
19366                root,
19367                Size {
19368                    width: 240.0,
19369                    height: 240.0,
19370                },
19371            )
19372            .expect("lazy column layout");
19373        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
19374        applier.clear_runtime_handle();
19375        let mut graph_labels = Vec::new();
19376        collect_graph_text_labels(&graph.root, &mut graph_labels);
19377
19378        let visible_indices: Vec<_> = list_state
19379            .layout_info()
19380            .visible_items_info
19381            .iter()
19382            .map(|item| item.index)
19383            .collect();
19384        assert_eq!(
19385            visible_indices,
19386            vec![4, 5, 6],
19387            "test setup expects exactly three viewport-visible rows"
19388        );
19389
19390        let mut rect_cache = HashMap::new();
19391        let mut requirements_cache = HashMap::new();
19392        let collected = with_test_app_context(|| {
19393            collect_layer_contents(
19394                &graph.root,
19395                None,
19396                None,
19397                &mut rect_cache,
19398                &mut requirements_cache,
19399            )
19400        });
19401        let root_text_labels: Vec<_> = collected
19402            .scene
19403            .texts
19404            .iter()
19405            .map(|text| text.text.text.clone())
19406            .collect();
19407        let child_layer_count = collected.child_layers.len();
19408        let warm_text = collected
19409            .scene
19410            .texts
19411            .iter()
19412            .find(|text| text.text.text == "WarmRow 7")
19413            .unwrap_or_else(|| {
19414                panic!(
19415                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
19416                )
19417            });
19418
19419        assert!(
19420            warm_text.rect.y >= 96.0,
19421            "after-bound text should be below the viewport, got {:?}",
19422            warm_text.rect
19423        );
19424        assert_eq!(
19425            visible_draw_rect(warm_text.rect, warm_text.clip),
19426            None,
19427            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
19428        );
19429        assert!(
19430            text_draw_should_prewarm_in_viewport(
19431                warm_text.rect,
19432                warm_text.clip,
19433                ViewportUniformParams {
19434                    width: 240,
19435                    height: 96,
19436                    offset: [0.0, 0.0],
19437                },
19438                1.0,
19439            ),
19440            "after-bound text inside the warm window must be selected by WGPU prewarm"
19441        );
19442    }
19443
19444    #[test]
19445    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
19446        let local_bounds = Rect {
19447            x: 0.0,
19448            y: 0.0,
19449            width: 393.3,
19450            height: 16.8,
19451        };
19452        let quad = [
19453            [10.0, 78.399_994],
19454            [403.3, 78.399_994],
19455            [10.0, 95.2],
19456            [403.3, 95.2],
19457        ];
19458        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
19459
19460        assert_eq!(
19461            direct_translation(transform),
19462            Some(Point::new(10.0, 78.399_994)),
19463        );
19464    }
19465
19466    #[test]
19467    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
19468        let mut child = test_layer(
19469            Rect {
19470                x: 0.0,
19471                y: 0.0,
19472                width: 24.0,
19473                height: 18.0,
19474            },
19475            vec![RenderNode::Primitive(PrimitiveEntry {
19476                phase: PrimitivePhase::BeforeChildren,
19477                node: PrimitiveNode::Draw(DrawPrimitiveNode {
19478                    primitive: DrawPrimitive::Rect {
19479                        rect: Rect {
19480                            x: 0.0,
19481                            y: 0.0,
19482                            width: 24.0,
19483                            height: 18.0,
19484                        },
19485                        brush: Brush::solid(Color::WHITE),
19486                        stroke: None,
19487                    },
19488                    clip: None,
19489                }),
19490            })],
19491        );
19492        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
19493        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
19494
19495        let layer = test_layer(
19496            Rect {
19497                x: 0.0,
19498                y: 0.0,
19499                width: 64.0,
19500                height: 32.0,
19501            },
19502            vec![
19503                RenderNode::Primitive(PrimitiveEntry {
19504                    phase: PrimitivePhase::BeforeChildren,
19505                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
19506                        primitive: DrawPrimitive::Rect {
19507                            rect: Rect {
19508                                x: 0.0,
19509                                y: 0.0,
19510                                width: 64.0,
19511                                height: 32.0,
19512                            },
19513                            brush: Brush::solid(Color::BLACK),
19514                            stroke: None,
19515                        },
19516                        clip: None,
19517                    }),
19518                }),
19519                RenderNode::Layer(Box::new(child)),
19520            ],
19521        );
19522
19523        let requirements = layer_surface_requirements(&layer);
19524
19525        assert!(requirements
19526            .surface_requirements
19527            .contains(SurfaceRequirement::MixedDirectContent));
19528        assert!(!requirements
19529            .surface_requirements
19530            .has_isolating_requirement());
19531    }
19532
19533    #[test]
19534    fn build_scene_window_filters_and_translates_items() {
19535        let mut shape = test_shape(6, BlendMode::SrcOver);
19536        shape.rect.x = 12.0;
19537        shape.rect.y = 25.0;
19538        shape.local_rect.x = 12.0;
19539        shape.local_rect.y = 25.0;
19540        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
19541        shape.clip = Some(Rect {
19542            x: 11.0,
19543            y: 24.0,
19544            width: 10.0,
19545            height: 10.0,
19546        });
19547
19548        let mut image = test_image(8, BlendMode::SrcOver);
19549        image.rect.x = 18.0;
19550        image.rect.y = 27.0;
19551        image.local_rect.x = 18.0;
19552        image.local_rect.y = 27.0;
19553        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
19554
19555        let mut text = test_text(9);
19556        text.rect.x = 16.0;
19557        text.rect.y = 29.0;
19558        text.clip = Some(Rect {
19559            x: 15.0,
19560            y: 28.0,
19561            width: 9.0,
19562            height: 6.0,
19563        });
19564
19565        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
19566        shadow_shape.rect.x = 14.0;
19567        shadow_shape.rect.y = 26.0;
19568        shadow_shape.local_rect.x = 14.0;
19569        shadow_shape.local_rect.y = 26.0;
19570        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
19571        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
19572        shadow.z_index = 7;
19573
19574        let mut nested_effect = effect_layer(6, 10);
19575        nested_effect.rect.x = 13.0;
19576        nested_effect.rect.y = 24.0;
19577        nested_effect.clip = Some(Rect {
19578            x: 15.0,
19579            y: 25.0,
19580            width: 4.0,
19581            height: 5.0,
19582        });
19583
19584        let mut nested_backdrop = backdrop_layer(8);
19585        nested_backdrop.rect.x = 17.0;
19586        nested_backdrop.rect.y = 26.0;
19587        nested_backdrop.clip = Some(Rect {
19588            x: 18.0,
19589            y: 27.0,
19590            width: 3.0,
19591            height: 4.0,
19592        });
19593
19594        let window = build_scene_window(
19595            SceneWindowSource {
19596                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
19597                brushes: &[],
19598                images: &[image],
19599                texts: &[text],
19600                shadow_draws: &[shadow],
19601                draw_ops: &[],
19602                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
19603                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
19604            },
19605            5,
19606            10,
19607            Rect {
19608                x: 10.0,
19609                y: 20.0,
19610                width: 20.0,
19611                height: 20.0,
19612            },
19613        );
19614
19615        assert_eq!(window.shapes.len(), 1);
19616        assert_eq!(
19617            window.shapes[0].rect,
19618            Rect {
19619                x: 2.0,
19620                y: 5.0,
19621                width: 8.0,
19622                height: 8.0,
19623            }
19624        );
19625        assert_eq!(
19626            window.shapes[0].clip,
19627            Some(Rect {
19628                x: 1.0,
19629                y: 4.0,
19630                width: 10.0,
19631                height: 10.0,
19632            })
19633        );
19634        assert_eq!(window.images.len(), 1);
19635        assert_eq!(window.images[0].rect.x, 8.0);
19636        assert_eq!(window.images[0].rect.y, 7.0);
19637        assert_eq!(window.texts.len(), 1);
19638        assert_eq!(window.texts[0].rect.x, 6.0);
19639        assert_eq!(window.texts[0].rect.y, 9.0);
19640        assert_eq!(
19641            window.texts[0].clip,
19642            Some(Rect {
19643                x: 5.0,
19644                y: 8.0,
19645                width: 9.0,
19646                height: 6.0,
19647            })
19648        );
19649        assert_eq!(window.shadow_draws.len(), 1);
19650        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
19651        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
19652        assert_eq!(window.effect_layers.len(), 1);
19653        assert_eq!(
19654            window.effect_layers[0].rect,
19655            Rect {
19656                x: 3.0,
19657                y: 4.0,
19658                width: 10.0,
19659                height: 10.0,
19660            }
19661        );
19662        assert_eq!(
19663            window.effect_layers[0].clip,
19664            Some(Rect {
19665                x: 5.0,
19666                y: 5.0,
19667                width: 4.0,
19668                height: 5.0,
19669            })
19670        );
19671        assert_eq!(window.backdrop_layers.len(), 1);
19672        assert_eq!(
19673            window.backdrop_layers[0].rect,
19674            Rect {
19675                x: 7.0,
19676                y: 6.0,
19677                width: 10.0,
19678                height: 10.0,
19679            }
19680        );
19681        assert_eq!(
19682            window.backdrop_layers[0].clip,
19683            Some(Rect {
19684                x: 8.0,
19685                y: 7.0,
19686                width: 3.0,
19687                height: 4.0,
19688            })
19689        );
19690    }
19691
19692    #[test]
19693    fn filtered_effect_layer_index_counts_only_window_members() {
19694        let effects = vec![
19695            effect_layer(0, 2),
19696            effect_layer(5, 12),
19697            effect_layer(6, 10),
19698            effect_layer(14, 20),
19699        ];
19700
19701        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
19702        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
19703        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
19704    }
19705
19706    #[test]
19707    fn blend_mode_support_matrix_is_explicit() {
19708        assert!(is_blend_mode_supported(BlendMode::SrcOver));
19709        assert!(is_blend_mode_supported(BlendMode::DstOut));
19710        assert!(!is_blend_mode_supported(BlendMode::Clear));
19711        assert!(!is_blend_mode_supported(BlendMode::Multiply));
19712    }
19713
19714    #[test]
19715    fn collect_non_effect_segment_items_preserves_global_z_order() {
19716        let shapes = vec![
19717            test_shape(3, BlendMode::SrcOver),
19718            test_shape(1, BlendMode::DstOut),
19719        ];
19720        let images = vec![test_image(2, BlendMode::SrcOver)];
19721        let texts = vec![test_text(0)];
19722        let shadows: Vec<ShadowDraw> = Vec::new();
19723        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
19724
19725        let mut scratch = Vec::new();
19726        collect_non_effect_segment_items(
19727            &shapes,
19728            &images,
19729            &texts,
19730            &shadows,
19731            &draw_ops,
19732            0,
19733            4,
19734            &[],
19735            100,
19736            100,
19737            1.0,
19738            &mut scratch,
19739        );
19740        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
19741        assert_eq!(
19742            items,
19743            vec![
19744                SegmentDrawItem::Text(0),
19745                SegmentDrawItem::Shape(1),
19746                SegmentDrawItem::Image(0),
19747                SegmentDrawItem::Shape(0),
19748            ]
19749        );
19750    }
19751
19752    #[test]
19753    fn collect_non_effect_segment_items_filters_effect_ranges() {
19754        let shapes = vec![
19755            test_shape(1, BlendMode::SrcOver),
19756            test_shape(3, BlendMode::DstOut),
19757        ];
19758        let images = vec![test_image(2, BlendMode::SrcOver)];
19759        let texts = vec![test_text(4)];
19760        let shadows: Vec<ShadowDraw> = Vec::new();
19761        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
19762        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
19763
19764        let mut scratch = Vec::new();
19765        collect_non_effect_segment_items(
19766            &shapes,
19767            &images,
19768            &texts,
19769            &shadows,
19770            &draw_ops,
19771            0,
19772            5,
19773            &effect_ranges,
19774            100,
19775            100,
19776            1.0,
19777            &mut scratch,
19778        );
19779        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
19780        assert_eq!(
19781            items,
19782            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
19783        );
19784    }
19785
19786    #[test]
19787    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
19788        let mut shape = test_shape(0, BlendMode::SrcOver);
19789        shape.rect.y = 160.0;
19790        shape.local_rect.y = 160.0;
19791        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
19792
19793        let shapes = vec![shape];
19794        let images = Vec::new();
19795        let mut text = test_text(1);
19796        text.rect.y = 160.0;
19797        let texts = vec![text];
19798        let shadows: Vec<ShadowDraw> = Vec::new();
19799        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
19800
19801        let mut scratch = Vec::new();
19802        collect_non_effect_segment_items(
19803            &shapes,
19804            &images,
19805            &texts,
19806            &shadows,
19807            &draw_ops,
19808            0,
19809            2,
19810            &[],
19811            100,
19812            100,
19813            1.0,
19814            &mut scratch,
19815        );
19816
19817        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
19818        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
19819    }
19820
19821    #[test]
19822    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
19823        let ordered_items = vec![
19824            (0, SegmentDrawItem::Shape(0)),
19825            (1, SegmentDrawItem::Image(0)),
19826            (2, SegmentDrawItem::Text(0)),
19827        ];
19828        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
19829        let images = vec![test_image(1, BlendMode::DstOut)];
19830
19831        let commands: Vec<_> = SegmentCommandIter::new(
19832            &ordered_items,
19833            &shapes,
19834            &images,
19835            ShapeBatchLimits::desktop(),
19836        )
19837        .collect();
19838
19839        assert_eq!(
19840            commands,
19841            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19842                SegmentBatchPlan::Shape {
19843                    start: 0,
19844                    end: 1,
19845                    blend_mode: BlendMode::SrcOver,
19846                },
19847                SegmentBatchPlan::Image {
19848                    start: 1,
19849                    end: 2,
19850                    blend_mode: BlendMode::DstOut,
19851                },
19852                SegmentBatchPlan::Text { start: 2, end: 3 },
19853            ]))]
19854        );
19855    }
19856
19857    #[test]
19858    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
19859        let ordered_items = vec![
19860            (0, SegmentDrawItem::Shape(0)),
19861            (1, SegmentDrawItem::Composite(0)),
19862            (2, SegmentDrawItem::Image(0)),
19863            (3, SegmentDrawItem::Composite(1)),
19864            (4, SegmentDrawItem::Text(0)),
19865        ];
19866        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
19867        let images = vec![test_image(2, BlendMode::SrcOver)];
19868
19869        let commands: Vec<_> = SegmentCommandIter::new(
19870            &ordered_items,
19871            &shapes,
19872            &images,
19873            ShapeBatchLimits::desktop(),
19874        )
19875        .collect();
19876
19877        assert_eq!(
19878            commands,
19879            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19880                SegmentBatchPlan::Shape {
19881                    start: 0,
19882                    end: 1,
19883                    blend_mode: BlendMode::SrcOver,
19884                },
19885                SegmentBatchPlan::Composite { start: 1, end: 2 },
19886                SegmentBatchPlan::Image {
19887                    start: 2,
19888                    end: 3,
19889                    blend_mode: BlendMode::SrcOver,
19890                },
19891                SegmentBatchPlan::Composite { start: 3, end: 4 },
19892                SegmentBatchPlan::Text { start: 4, end: 5 },
19893            ]))]
19894        );
19895    }
19896
19897    #[test]
19898    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
19899        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
19900        let images = vec![test_image(2, BlendMode::SrcOver)];
19901        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
19902        shadow_shape.rect = Rect {
19903            x: 500.0,
19904            y: 500.0,
19905            width: 12.0,
19906            height: 12.0,
19907        };
19908        let shadow_draws = vec![ShadowDraw {
19909            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
19910            brushes: vec![],
19911            texts: Vec::new(),
19912            blur_radius: 8.0,
19913            clip: None,
19914            z_index: 1,
19915        }];
19916        let mut ordered_items = vec![
19917            (0, SegmentDrawItem::Shape(0)),
19918            (1, SegmentDrawItem::Shadow(0)),
19919            (2, SegmentDrawItem::Image(0)),
19920        ];
19921
19922        let culled =
19923            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
19924        let commands: Vec<_> = SegmentCommandIter::new(
19925            &ordered_items,
19926            &shapes,
19927            &images,
19928            ShapeBatchLimits::desktop(),
19929        )
19930        .collect();
19931
19932        assert_eq!(culled, 1);
19933        assert_eq!(
19934            commands,
19935            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19936                SegmentBatchPlan::Shape {
19937                    start: 0,
19938                    end: 1,
19939                    blend_mode: BlendMode::SrcOver,
19940                },
19941                SegmentBatchPlan::Image {
19942                    start: 1,
19943                    end: 2,
19944                    blend_mode: BlendMode::SrcOver,
19945                },
19946            ]))]
19947        );
19948    }
19949
19950    #[test]
19951    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
19952        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
19953        shadow_shape.rect = Rect {
19954            x: 20.0,
19955            y: 20.0,
19956            width: 12.0,
19957            height: 12.0,
19958        };
19959        let shadow_draws = vec![ShadowDraw {
19960            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
19961            brushes: vec![],
19962            texts: Vec::new(),
19963            blur_radius: 8.0,
19964            clip: None,
19965            z_index: 1,
19966        }];
19967        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
19968
19969        let culled =
19970            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
19971
19972        assert_eq!(culled, 0);
19973        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
19974    }
19975
19976    #[test]
19977    fn shape_data_layout_matches_the_wgsl_mirror() {
19978        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
19979        // multiple, and `shape.wgsl`'s array length literal is derived from
19980        // this size — if it drifts, batches silently overrun the binding.
19981        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
19982        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
19983        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
19984    }
19985
19986    #[test]
19987    fn shape_flags_pack_kind_cap_and_join_without_collision() {
19988        assert_eq!(
19989            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
19990            0.0
19991        );
19992        assert_eq!(
19993            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
19994            1.0
19995        );
19996        assert_eq!(
19997            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
19998            2.0
19999        );
20000        // cap in bits 2-3, join in bits 4-5
20001        assert_eq!(
20002            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
20003            2.0 + 4.0
20004        );
20005        assert_eq!(
20006            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
20007            2.0 + 8.0
20008        );
20009        assert_eq!(
20010            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
20011            1.0 + 16.0
20012        );
20013        assert_eq!(
20014            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
20015            1.0 + 32.0
20016        );
20017        // Every combination must round-trip through f32 exactly.
20018        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
20019            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
20020                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
20021                    let packed = pack_shape_flags(kind, cap, join);
20022                    let bits = packed as u32;
20023                    assert_eq!(bits & 3, kind);
20024                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
20025                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
20026                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
20027                }
20028            }
20029        }
20030    }
20031
20032    #[cfg(not(target_arch = "wasm32"))]
20033    #[test]
20034    fn mesh_vertex_layout_matches_the_wgsl_input() {
20035        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
20036        // padding — the vertex buffer layout stride relies on it.
20037        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
20038    }
20039
20040    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
20041    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
20042    /// rotated x), the same cap branches.
20043    #[cfg(not(target_arch = "wasm32"))]
20044    #[allow(clippy::too_many_arguments)]
20045    fn sdf_arc_band_reference(
20046        p: [f32; 2],
20047        center: [f32; 2],
20048        inner: f32,
20049        outer: f32,
20050        mid_sin_cos: [f32; 2],
20051        half_sin_cos: [f32; 2],
20052        cap: u32,
20053    ) -> f32 {
20054        let ra = (outer + inner) * 0.5;
20055        let rb = ((outer - inner) * 0.5).max(0.0);
20056        let sm = mid_sin_cos[0];
20057        let cm = mid_sin_cos[1];
20058        let d = [p[0] - center[0], p[1] - center[1]];
20059        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
20060        q[0] = q[0].abs();
20061        let sc = half_sin_cos;
20062        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
20063            let dx = q[0] - sc[0] * ra;
20064            let dy = q[1] - sc[1] * ra;
20065            (dx * dx + dy * dy).sqrt() - rb
20066        } else {
20067            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
20068        };
20069        let plane = sc[1] * q[0] - sc[0] * q[1];
20070        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
20071        if cap == 0 {
20072            dist = dist.max(plane);
20073        } else if cap == 2 {
20074            dist = dist.max(plane - rb);
20075        }
20076        dist
20077    }
20078
20079    #[cfg(not(target_arch = "wasm32"))]
20080    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
20081        let side = |a: [f64; 2], b: [f64; 2]| {
20082            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
20083        };
20084        let d0 = side(tri[0], tri[1]);
20085        let d1 = side(tri[1], tri[2]);
20086        let d2 = side(tri[2], tri[0]);
20087        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
20088        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
20089        !(has_neg && has_pos)
20090    }
20091
20092    #[cfg(not(target_arch = "wasm32"))]
20093    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
20094        let bounds = arc.bounds();
20095        let mut shape = test_shape(0, BlendMode::SrcOver);
20096        shape.rect = bounds;
20097        shape.local_rect = bounds;
20098        shape.quad = [
20099            [bounds.x, bounds.y],
20100            [bounds.x + bounds.width, bounds.y],
20101            [bounds.x, bounds.y + bounds.height],
20102            [bounds.x + bounds.width, bounds.y + bounds.height],
20103        ];
20104        shape.arc = Some(arc);
20105        let mut converted = ShapeData::zeroed();
20106        convert_shape_into_slots(&shape, &[], root_scale, 0, &mut converted, &mut []);
20107        converted
20108    }
20109
20110    /// The containment invariant, checked directly: every point of the
20111    /// capture box whose (exactly ported) SDF keeps it must lie inside the
20112    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
20113    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
20114    #[cfg(not(target_arch = "wasm32"))]
20115    #[test]
20116    fn arc_mesh_contains_every_band_pixel() {
20117        use cranpose_ui_graphics::ArcGeometry;
20118        let tau = cranpose_ui_graphics::TAU;
20119        let center = Point::new(250.0, 250.0);
20120        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
20121            // full ring, thin band
20122            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
20123            // sweep > TAU normalizes to a closed ring
20124            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
20125            // full disc: Ri == 0
20126            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
20127            // thick partial arc, every cap
20128            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
20129            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
20130            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
20131            // thin, axis-crossing sweep
20132            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
20133            // tiny
20134            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
20135            // huge radius, thin band
20136            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
20137            // near-zero sweep
20138            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
20139            // sweep near TAU: the cap pads wrap the range closed
20140            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
20141            // rb_m >= ra: the cap disc wraps the center (pie wedge)
20142            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
20143            // filled annular sector (butt radial ends)
20144            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
20145        ];
20146        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
20147            // 2.75 is deliberately non-dyadic: quad corners and rect then
20148            // disagree by an ulp, which the axis-aligned gate must tolerate
20149            // (an equality-with-rect gate silently failed every arc on the
20150            // Huawei at scale 2.75).
20151            for root_scale in [1.0f32, 2.0, 2.75] {
20152                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
20153                assert!(!arc.is_degenerate(), "case {case} must be drawable");
20154                let converted = converted_arc_shape(arc, root_scale);
20155                let band = arc_mesh_band(&converted)
20156                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
20157                let mut vertices = Vec::new();
20158                let mut indices = Vec::new();
20159                let segments =
20160                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
20161                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
20162                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
20163                // The rasterized set is the indexed walk: triangles are index
20164                // triples into the shared vertex list.
20165                let position = |index: u32| {
20166                    let p = vertices[index as usize].position;
20167                    [p[0] as f64, p[1] as f64]
20168                };
20169                let triangles: Vec<[[f64; 2]; 3]> = indices
20170                    .as_chunks::<3>()
20171                    .0
20172                    .iter()
20173                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
20174                    .collect();
20175
20176                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
20177                // quad, the mesh clips to the quad, and at non-dyadic root
20178                // scales the two boxes differ by an ulp.
20179                let [qx, qy, ..] = converted.quad01;
20180                let [_, _, qr, qb] = converted.quad23;
20181                let (rw, rh) = (qr - qx, qb - qy);
20182                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
20183                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
20184                let mut band_points = 0usize;
20185                let mut y = qy;
20186                while y <= qb {
20187                    let mut x = qx;
20188                    while x <= qr {
20189                        let dist = sdf_arc_band_reference(
20190                            [x, y],
20191                            [converted.arc_params[0], converted.arc_params[1]],
20192                            converted.stroke_params[3],
20193                            converted.stroke_params[2],
20194                            [converted.radii[0], converted.radii[1]],
20195                            [converted.radii[2], converted.radii[3]],
20196                            cap_bits,
20197                        );
20198                        if dist <= 0.5 {
20199                            band_points += 1;
20200                            let p = [x as f64, y as f64];
20201                            assert!(
20202                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
20203                                "case {case} scale {root_scale}: band point ({x}, {y}) \
20204                                 dist {dist} escapes the mesh"
20205                            );
20206                        }
20207                        x += step;
20208                    }
20209                    y += step;
20210                }
20211                assert!(
20212                    band_points > 0,
20213                    "case {case} scale {root_scale}: the sampling grid never hit the band"
20214                );
20215            }
20216        }
20217    }
20218
20219    #[cfg(not(target_arch = "wasm32"))]
20220    #[test]
20221    fn arc_mesh_passthrough_replicates_the_quad_expansion() {
20222        let shape = test_shape(0, BlendMode::SrcOver);
20223        let mut converted = ShapeData::zeroed();
20224        convert_shape_into_slots(&shape, &[], 1.0, 0, &mut converted, &mut []);
20225        let build =
20226            build_arc_mesh_vertices(std::slice::from_ref(&converted)).expect("within budget");
20227        assert_eq!(build.meshed_arcs, 0);
20228        assert_eq!(build.passthrough, 1);
20229        // Four shared corner vertices, six indices — amplification-free.
20230        assert_eq!(build.vertices.len(), 4);
20231        assert_eq!(build.index_prefix, vec![0, 6]);
20232        assert_eq!(build.indices, vec![0, 1, 2, 2, 1, 3]);
20233        let corners = [
20234            ([converted.quad01[0], converted.quad01[1]], [0.0f32, 0.0]),
20235            ([converted.quad01[2], converted.quad01[3]], [1.0, 0.0]),
20236            ([converted.quad23[0], converted.quad23[1]], [0.0, 1.0]),
20237            ([converted.quad23[2], converted.quad23[3]], [1.0, 1.0]),
20238        ];
20239        for (vertex, corner) in build.vertices.iter().zip(corners) {
20240            assert_eq!(vertex.position, corner.0);
20241            assert_eq!(vertex.uv, corner.1);
20242            assert_eq!(vertex.shape_idx, 0);
20243        }
20244        // The indexed walk expands to vs_main's slot order: triangles
20245        // (0, 1, 2) and (2, 1, 3).
20246        for (index, corner) in build.indices.iter().zip([0usize, 1, 2, 2, 1, 3]) {
20247            assert_eq!(build.vertices[*index as usize].position, corners[corner].0);
20248            assert_eq!(build.vertices[*index as usize].uv, corners[corner].1);
20249        }
20250    }
20251
20252    /// The indexed-topology contract for arcs whose trapezoids survive
20253    /// clipping whole: every band boundary contributes exactly one (inner,
20254    /// outer) vertex pair, both adjacent trapezoids reference it through the
20255    /// index list, and a closed ring's last segment wraps around to boundary
20256    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
20257    #[cfg(not(target_arch = "wasm32"))]
20258    #[test]
20259    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
20260        use cranpose_ui_graphics::ArcGeometry;
20261        let tau = cranpose_ui_graphics::TAU;
20262        // (sweep, expected boundary count relation): a closed ring wraps
20263        // (boundaries == segments), an open arc does not (segments + 1).
20264        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
20265            let arc = ArcGeometry::new(
20266                Point::new(250.0, 250.0),
20267                80.0,
20268                100.0,
20269                0.7,
20270                sweep,
20271                StrokeCap::Round,
20272            );
20273            let mut converted = converted_arc_shape(arc, 1.0);
20274            // Inflate the quad box (and rect, for uv) far beyond the dilated
20275            // band so NO trapezoid is clipped: every segment must take the
20276            // shared-boundary path.
20277            converted.rect = [0.0, 0.0, 500.0, 500.0];
20278            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
20279            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
20280            let band = arc_mesh_band(&converted).expect("arc must qualify");
20281            let mut vertices = Vec::new();
20282            let mut indices = Vec::new();
20283            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
20284                .expect("arc must mesh");
20285            let boundary_count = if closed { segments } else { segments + 1 };
20286            assert_eq!(
20287                vertices.len(),
20288                2 * boundary_count,
20289                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
20290            );
20291            assert_eq!(indices.len(), 6 * segments);
20292            // Emission order is boundary order: boundary j's pair is
20293            // (2j, 2j + 1). Each segment must reference its own boundary and
20294            // its successor's — modulo the count exactly when closed.
20295            for j in 0..segments {
20296                let jb = (j + 1) % boundary_count;
20297                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
20298                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
20299                assert_eq!(
20300                    indices[6 * j..6 * j + 6],
20301                    [in_a, out_a, out_b, in_a, out_b, in_b],
20302                    "closed={closed}: segment {j} must share its boundary pairs"
20303                );
20304            }
20305            if closed {
20306                // The wrap made concrete: the final segment indexes boundary
20307                // zero's vertices.
20308                assert_eq!(indices[6 * segments - 1], 0);
20309            }
20310            // Inner vertices ride the dilated inner radius, outer vertices
20311            // the pushed-out chord radius — sanity that pairs are ordered
20312            // (inner, outer).
20313            for pair in vertices.as_chunks::<2>().0 {
20314                let radius = |v: &MeshVertex| {
20315                    let dx = v.position[0] - 250.0;
20316                    let dy = v.position[1] - 250.0;
20317                    (dx * dx + dy * dy).sqrt()
20318                };
20319                assert!(radius(&pair[0]) < radius(&pair[1]));
20320            }
20321        }
20322    }
20323
20324    /// The private-vertex arm of the indexed topology: under the real
20325    /// tight-AABB quad the pushed-out chord vertices near the box edges get
20326    /// clipped, and those trapezoids must fan over vertices of their own —
20327    /// appended after the shared block, carrying clip-plane coordinates —
20328    /// while untouched diagonal trapezoids still share boundary pairs.
20329    #[cfg(not(target_arch = "wasm32"))]
20330    #[test]
20331    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
20332        use cranpose_ui_graphics::ArcGeometry;
20333        let arc = ArcGeometry::new(
20334            Point::new(250.0, 250.0),
20335            80.0,
20336            100.0,
20337            0.0,
20338            cranpose_ui_graphics::TAU,
20339            StrokeCap::Round,
20340        );
20341        let converted = converted_arc_shape(arc, 1.0);
20342        let band = arc_mesh_band(&converted).expect("ring must qualify");
20343        let mut vertices = Vec::new();
20344        let mut indices = Vec::new();
20345        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
20346            .expect("ring must mesh");
20347        // Sharing must actually happen: a shared boundary vertex is used by
20348        // both of its trapezoids' fans (at least three triangle references).
20349        let mut uses = vec![0usize; vertices.len()];
20350        for &index in &indices {
20351            uses[index as usize] += 1;
20352        }
20353        assert!(
20354            uses.iter().any(|&count| count >= 3),
20355            "some boundary vertices must be shared across trapezoids"
20356        );
20357        // Clipping must actually happen, and clipped polygons index private
20358        // vertices lying bitwise ON the quad box (the clipper writes the
20359        // bound coordinate exactly; boundary vertices never touch the box —
20360        // inner ones sit strictly inside, pushed-out outer ones strictly
20361        // outside near the extremes, where they are clipped).
20362        let [left, top, ..] = converted.quad01;
20363        let [.., right, bottom] = converted.quad23;
20364        let clipped: Vec<&MeshVertex> = vertices
20365            .iter()
20366            .filter(|vertex| {
20367                let [x, y] = vertex.position;
20368                x == left || x == right || y == top || y == bottom
20369            })
20370            .collect();
20371        assert!(
20372            !clipped.is_empty(),
20373            "the tight box must clip the pushed-out chord vertices"
20374        );
20375        // Fewer unique vertices than the non-indexed emitter's
20376        // three-per-triangle — the amplification this change removes.
20377        assert!(
20378            vertices.len() < indices.len(),
20379            "{} unique vertices should undercut {} triangle corners",
20380            vertices.len(),
20381            indices.len()
20382        );
20383    }
20384
20385    #[cfg(not(target_arch = "wasm32"))]
20386    #[test]
20387    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
20388        use cranpose_ui_graphics::ArcGeometry;
20389        // 100 large full rings mesh at the 64-segment ceiling (well over
20390        // 4 KB of vertices + indices each), far past the byte budget
20391        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
20392        // slot rather than truncate.
20393        let arc = ArcGeometry::new(
20394            Point::new(2000.0, 2000.0),
20395            1690.0,
20396            1710.0,
20397            0.0,
20398            cranpose_ui_graphics::TAU,
20399            StrokeCap::Round,
20400        );
20401        let converted = converted_arc_shape(arc, 1.0);
20402        let shapes = vec![converted; 100];
20403        assert!(build_arc_mesh_vertices(&shapes).is_none());
20404    }
20405
20406    /// A converted circle rim, hand-built in `ShapeData` terms: `rect` is the
20407    /// stroke-inflated 300×300 box, the geometry is 292×292, and the corner
20408    /// radius (300 − 8) / 2 = 146 equals the geometry half-extent — a circle.
20409    #[cfg(not(target_arch = "wasm32"))]
20410    fn rim_test_shape_data() -> ShapeData {
20411        let mut shape = ShapeData::zeroed();
20412        shape.rect = [40.0, 40.0, 300.0, 300.0];
20413        shape.radii = [146.0; 4];
20414        shape.stroke_params = [
20415            8.0,
20416            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
20417            0.0,
20418            0.0,
20419        ];
20420        shape.quad01 = [40.0, 40.0, 340.0, 40.0];
20421        shape.quad23 = [40.0, 340.0, 340.0, 340.0];
20422        shape.color = [1.0, 1.0, 1.0, 1.0];
20423        shape
20424    }
20425
20426    /// A viewport that never matches the diag's latched surface, so bucket
20427    /// tests exercise no corner accounting.
20428    #[cfg(not(target_arch = "wasm32"))]
20429    fn offscreen_test_viewport() -> ViewportUniformParams {
20430        ViewportUniformParams {
20431            width: 64,
20432            height: 64,
20433            offset: [7.0, 7.0],
20434        }
20435    }
20436
20437    #[cfg(not(target_arch = "wasm32"))]
20438    #[test]
20439    fn fill_diag_buckets_shape_quads_by_decoded_sdf_class() {
20440        let diag = FillAreaDiag::default();
20441        let mut arc = ShapeData::zeroed();
20442        arc.stroke_params[1] = pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter);
20443        // Arcs keep trig in `radii`; nonzero values there must not classify
20444        // the shape as a rounded fill.
20445        arc.radii = [0.5; 4];
20446        arc.quad01 = [0.0, 0.0, 10.0, 0.0];
20447        arc.quad23 = [0.0, 10.0, 10.0, 10.0];
20448        let mut rounded = ShapeData::zeroed();
20449        rounded.stroke_params[1] =
20450            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20451        rounded.radii = [2.0; 4];
20452        rounded.quad01 = [0.0, 0.0, 4.0, 0.0];
20453        rounded.quad23 = [0.0, 5.0, 4.0, 5.0];
20454        let mut plain = ShapeData::zeroed();
20455        plain.stroke_params[1] =
20456            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20457        plain.quad01 = [0.0, 0.0, 2.0, 0.0];
20458        plain.quad23 = [0.0, 3.0, 2.0, 3.0];
20459        diag.add_shape_quads(
20460            &[rim_test_shape_data(), arc, rounded, plain],
20461            offscreen_test_viewport(),
20462        );
20463        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 300.0 * 300.0);
20464        assert_eq!(diag.frame[FillAreaDiag::ARC].get(), 100.0);
20465        assert_eq!(diag.frame[FillAreaDiag::RRECT_FILL].get(), 20.0);
20466        assert_eq!(diag.frame[FillAreaDiag::RECT].get(), 6.0);
20467        // Off-frame passes never touch the corner counter.
20468        assert_eq!(diag.frame_corner.get(), 0.0);
20469        // Lit never exceeds the submitted area, bucket by bucket.
20470        for (lit, quad) in diag.frame_lit.iter().zip(&diag.frame) {
20471            assert!(lit.get() <= quad.get() + 1e-9);
20472        }
20473    }
20474
20475    #[cfg(not(target_arch = "wasm32"))]
20476    #[test]
20477    fn fill_diag_rim_mesh_moves_quad_area_to_the_mesh_bucket() {
20478        let diag = FillAreaDiag::default();
20479        diag.add_shape_quads(&[rim_test_shape_data()], offscreen_test_viewport());
20480        diag.note_rim_mesh(&rim_test_shape_data(), 1234.5);
20481        assert_eq!(diag.frame[FillAreaDiag::RRECT_STROKE].get(), 0.0);
20482        assert_eq!(diag.frame[FillAreaDiag::MESH].get(), 1234.5);
20483        // The lit accounting moves with the quad: nothing left in the
20484        // stroke bucket, and the mesh bucket's lit stays within the mesh.
20485        assert_eq!(diag.frame_lit[FillAreaDiag::RRECT_STROKE].get(), 0.0);
20486        assert!(diag.frame_lit[FillAreaDiag::MESH].get() <= 1234.5);
20487        assert!(diag.frame_lit[FillAreaDiag::MESH].get() > 0.0);
20488    }
20489
20490    #[cfg(not(target_arch = "wasm32"))]
20491    #[test]
20492    fn fill_diag_image_and_glyph_quads_share_one_bucket() {
20493        let diag = FillAreaDiag::default();
20494        diag.add_image_quad(&[[0.0, 0.0], [8.0, 0.0], [0.0, 4.0], [8.0, 4.0]]);
20495        let quad = CachedTextGlyphQuad {
20496            x: 0,
20497            y: 0,
20498            width: 5,
20499            height: 7,
20500            color: (1.0, 1.0, 1.0, 1.0),
20501            uv: ImageUvRect {
20502                min: [0.0, 0.0],
20503                max: [1.0, 1.0],
20504                sample_bounds: [0.0, 0.0, 1.0, 1.0],
20505            },
20506        };
20507        diag.add_glyph_quad(&quad);
20508        assert_eq!(diag.frame[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
20509        // Textures light their whole quad: lit tracks the submitted area.
20510        assert_eq!(diag.frame_lit[FillAreaDiag::IMAGE_GLYPH].get(), 32.0 + 35.0);
20511    }
20512
20513    /// Midpoint-rule area of `inside` over `bounds` (min x, min y, max x,
20514    /// max y), the reference the analytic-lit formulas are tested against.
20515    #[cfg(not(target_arch = "wasm32"))]
20516    fn numeric_area(bounds: [f64; 4], steps: usize, inside: impl Fn(f64, f64) -> bool) -> f64 {
20517        let dx = (bounds[2] - bounds[0]) / steps as f64;
20518        let dy = (bounds[3] - bounds[1]) / steps as f64;
20519        let mut area = 0.0;
20520        for column in 0..steps {
20521            let x = bounds[0] + (column as f64 + 0.5) * dx;
20522            for row in 0..steps {
20523                let y = bounds[1] + (row as f64 + 0.5) * dy;
20524                if inside(x, y) {
20525                    area += dx * dy;
20526                }
20527            }
20528        }
20529        area
20530    }
20531
20532    /// f64 rounded-rect SDF (uniform radius), the reference for the
20533    /// round-rect fill and stroke lit formulas.
20534    #[cfg(not(target_arch = "wasm32"))]
20535    fn sdf_rounded_rect_reference(
20536        p: [f64; 2],
20537        center: [f64; 2],
20538        half: [f64; 2],
20539        radius: f64,
20540    ) -> f64 {
20541        let qx = (p[0] - center[0]).abs() - (half[0] - radius);
20542        let qy = (p[1] - center[1]).abs() - (half[1] - radius);
20543        qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius
20544    }
20545
20546    #[cfg(not(target_arch = "wasm32"))]
20547    #[test]
20548    fn fill_truth_arc_lit_matches_the_sdf_covered_area() {
20549        use cranpose_ui_graphics::ArcGeometry;
20550        let tau = cranpose_ui_graphics::TAU;
20551        let center = Point::new(250.0, 250.0);
20552        // (inner, outer, start, sweep, cap): partial arcs with every cap,
20553        // a closed ring, and a full disc.
20554        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
20555            (90.0, 100.0, 0.7, 2.5, StrokeCap::Butt),
20556            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
20557            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
20558            (80.0, 100.0, 0.0, tau, StrokeCap::Round),
20559            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
20560        ];
20561        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
20562            let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
20563            let converted = converted_arc_shape(arc, 1.0);
20564            let cap_code = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
20565            let arc_center = [converted.arc_params[0], converted.arc_params[1]];
20566            let mid = [converted.radii[0], converted.radii[1]];
20567            let half = [converted.radii[2], converted.radii[3]];
20568            let aabb = quad_aabb(&converted);
20569            // Pad past the fast-trig AABB slop so the whole kept set is
20570            // integrated.
20571            let bounds = [aabb[0] - 2.0, aabb[1] - 2.0, aabb[2] + 2.0, aabb[3] + 2.0];
20572            let numeric = numeric_area(bounds, 1000, |x, y| {
20573                sdf_arc_band_reference(
20574                    [x as f32, y as f32],
20575                    arc_center,
20576                    converted.stroke_params[3],
20577                    converted.stroke_params[2],
20578                    mid,
20579                    half,
20580                    cap_code,
20581                ) < 0.0
20582            });
20583            let analytic = analytic_covered_area(&converted);
20584            let error = (analytic - numeric).abs() / numeric.max(1.0);
20585            assert!(
20586                error < 0.02,
20587                "case {case}: analytic {analytic:.1} vs sdf {numeric:.1} \
20588                 ({:.2}% off)",
20589                error * 100.0
20590            );
20591        }
20592    }
20593
20594    #[cfg(not(target_arch = "wasm32"))]
20595    #[test]
20596    fn fill_truth_circle_and_rrect_fill_lit_match_references() {
20597        // A filled circle degenerates to exactly pi r^2.
20598        let mut circle = ShapeData::zeroed();
20599        circle.stroke_params[1] =
20600            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20601        circle.rect = [10.0, 10.0, 200.0, 200.0];
20602        circle.radii = [100.0; 4];
20603        let analytic = analytic_covered_area(&circle);
20604        let exact = std::f64::consts::PI * 100.0 * 100.0;
20605        assert!(
20606            (analytic - exact).abs() / exact < 1e-9,
20607            "circle: {analytic} vs {exact}"
20608        );
20609
20610        // A rounded rect against the SDF reference.
20611        let mut rounded = ShapeData::zeroed();
20612        rounded.stroke_params[1] =
20613            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20614        rounded.rect = [50.0, 80.0, 200.0, 120.0];
20615        rounded.radii = [40.0; 4];
20616        let numeric = numeric_area([48.0, 78.0, 252.0, 202.0], 1000, |x, y| {
20617            sdf_rounded_rect_reference([x, y], [150.0, 140.0], [100.0, 60.0], 40.0) < 0.0
20618        });
20619        let analytic = analytic_covered_area(&rounded);
20620        let error = (analytic - numeric).abs() / numeric;
20621        assert!(
20622            error < 0.02,
20623            "rrect fill: analytic {analytic:.1} vs sdf {numeric:.1}"
20624        );
20625    }
20626
20627    #[cfg(not(target_arch = "wasm32"))]
20628    #[test]
20629    fn fill_truth_stroked_rrect_lit_matches_the_band_area() {
20630        // The circle rim: perimeter x stroke width equals the exact annulus
20631        // pi (outer^2 - inner^2) = 2 pi geom_half sw.
20632        let rim = rim_test_shape_data();
20633        let analytic = analytic_covered_area(&rim);
20634        let exact = std::f64::consts::PI * (150.0 * 150.0 - 142.0 * 142.0);
20635        assert!(
20636            (analytic - exact).abs() / exact < 1e-9,
20637            "circle rim: {analytic} vs {exact}"
20638        );
20639
20640        // A rounded-SQUARE ring (radius well below the half-extent) against
20641        // the SDF band |sdf| < sw/2.
20642        let mut square_ring = rim_test_shape_data();
20643        square_ring.radii = [60.0; 4];
20644        let numeric = numeric_area([38.0, 38.0, 342.0, 342.0], 1000, |x, y| {
20645            sdf_rounded_rect_reference([x, y], [190.0, 190.0], [146.0, 146.0], 60.0).abs() < 4.0
20646        });
20647        let analytic = analytic_covered_area(&square_ring);
20648        let error = (analytic - numeric).abs() / numeric;
20649        assert!(
20650            error < 0.02,
20651            "square ring: analytic {analytic:.1} vs sdf {numeric:.1}"
20652        );
20653    }
20654
20655    #[cfg(not(target_arch = "wasm32"))]
20656    #[test]
20657    fn fill_truth_corner_counter_prices_the_area_outside_the_inscribed_circle() {
20658        // A full-viewport quad on a square (watch) surface wastes exactly
20659        // the four corner lunes: (1 - pi/4) of the screen.
20660        let full = area_outside_inscribed_circle([0.0, 0.0, 454.0, 454.0], (454, 454));
20661        let exact = (1.0 - std::f64::consts::FRAC_PI_4) * 454.0 * 454.0;
20662        assert!(
20663            (full - exact).abs() / exact < 0.01,
20664            "full quad: {full} vs {exact}"
20665        );
20666        // A centered box inside the circle wastes nothing, exactly.
20667        assert_eq!(
20668            area_outside_inscribed_circle([127.0, 127.0, 327.0, 327.0], (454, 454)),
20669            0.0
20670        );
20671        // A box entirely inside a corner is all waste.
20672        let corner = area_outside_inscribed_circle([0.0, 0.0, 40.0, 40.0], (454, 454));
20673        assert!((corner - 1600.0).abs() < 1e-6, "corner box: {corner}");
20674    }
20675
20676    #[cfg(not(target_arch = "wasm32"))]
20677    #[test]
20678    fn fill_truth_opacity_histogram_classifies_solid_alpha_exactly() {
20679        let diag = FillAreaDiag::default();
20680        diag.reset_frame(454, 454);
20681        let full_frame = ViewportUniformParams {
20682            width: 454,
20683            height: 454,
20684            offset: [0.0, 0.0],
20685        };
20686        let mut opaque = ShapeData::zeroed();
20687        opaque.stroke_params[1] =
20688            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20689        opaque.rect = [0.0, 0.0, 100.0, 50.0];
20690        opaque.quad01 = [0.0, 0.0, 100.0, 0.0];
20691        opaque.quad23 = [0.0, 50.0, 100.0, 50.0];
20692        opaque.color = [1.0, 1.0, 1.0, 1.0];
20693        let mut faded = opaque;
20694        faded.color[3] = 0.82;
20695        let mut gradient = opaque;
20696        gradient.brush_type = 1;
20697        diag.add_shape_quads(&[opaque, faded, gradient], full_frame);
20698        // Plain rects are all-lit: 5000 px each, one per class.
20699        let lit = |class: FillOpacityClass| diag.frame_opacity[class as usize].get();
20700        assert_eq!(lit(FillOpacityClass::Opaque), 5000.0);
20701        assert_eq!(lit(FillOpacityClass::Translucent), 5000.0);
20702        assert_eq!(lit(FillOpacityClass::NonSolid), 5000.0);
20703        // The corner-hugging quads waste real area on a round display.
20704        assert!(diag.frame_corner.get() > 0.0);
20705
20706        // The same batch under an offset (offscreen) viewport must leave the
20707        // corner counter alone.
20708        let offscreen = FillAreaDiag::default();
20709        offscreen.reset_frame(454, 454);
20710        offscreen.add_shape_quads(&[opaque], offscreen_test_viewport());
20711        assert_eq!(offscreen.frame_corner.get(), 0.0);
20712    }
20713
20714    #[cfg(not(target_arch = "wasm32"))]
20715    #[test]
20716    fn fill_truth_retained_records_price_ranges_and_identity_corners() {
20717        let mut plain = ShapeData::zeroed();
20718        plain.stroke_params[1] =
20719            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20720        plain.rect = [200.0, 200.0, 20.0, 10.0];
20721        plain.quad01 = [200.0, 200.0, 220.0, 200.0];
20722        plain.quad23 = [200.0, 210.0, 220.0, 210.0];
20723        plain.color = [1.0, 1.0, 1.0, 1.0];
20724        let shapes = vec![rim_test_shape_data(), plain];
20725        let records = fill_diag_capture_records(&shapes, None);
20726        assert_eq!(records.len(), 2);
20727        assert_eq!(records[0].bucket, FillAreaDiag::RRECT_STROKE);
20728        assert_eq!(records[0].drawn_px2, 300.0 * 300.0);
20729        assert!(records[0].lit_px2 < records[0].drawn_px2, "a rim has slack");
20730        // A plain rect is exact: no slack at all.
20731        assert_eq!(records[1].bucket, FillAreaDiag::RECT);
20732        assert_eq!(records[1].lit_px2, records[1].drawn_px2);
20733
20734        let diag = FillAreaDiag::default();
20735        diag.reset_frame(454, 454);
20736        // Scaled replay: areas scale with the similarity squared, and the
20737        // capture-space AABBs no longer say where pixels land — no corner.
20738        let scaled = SimilarityTransform::new([0.0, 0.0], 0.0, 2.0);
20739        diag.add_retained_range(&records, 0, 2, &scaled);
20740        let drawn: f64 = records.iter().map(|record| record.drawn_px2).sum();
20741        assert!((diag.frame[FillAreaDiag::RETAINED].get() - drawn * 4.0).abs() < 1e-6);
20742        assert_eq!(diag.frame_corner.get(), 0.0);
20743
20744        // Identity replay: the rim's 300 px box on a 454 px round screen
20745        // pokes into the corner lunes.
20746        let identity_diag = FillAreaDiag::default();
20747        identity_diag.reset_frame(454, 454);
20748        identity_diag.add_retained_range(&records, 0, 2, &SimilarityTransform::IDENTITY);
20749        assert!(identity_diag.frame_corner.get() > 0.0);
20750        // And the range is respected: shape 1 alone has no rim slack.
20751        let tail = FillAreaDiag::default();
20752        tail.reset_frame(454, 454);
20753        tail.add_retained_range(&records, 1, 2, &SimilarityTransform::IDENTITY);
20754        assert_eq!(
20755            tail.frame[FillAreaDiag::RETAINED].get(),
20756            records[1].drawn_px2
20757        );
20758    }
20759
20760    #[cfg(not(target_arch = "wasm32"))]
20761    #[test]
20762    fn fill_truth_top_slack_dump_keeps_the_worst_ten() {
20763        let mut diag = FillAreaDiag::default();
20764        let records: Vec<FillDiagShapeRecord> = (0..12)
20765            .map(|index| FillDiagShapeRecord {
20766                drawn_px2: 1000.0 * (index + 1) as f64,
20767                lit_px2: 100.0,
20768                bucket: FillAreaDiag::ARC,
20769                opacity: FillOpacityClass::Opaque,
20770                aabb: [0.0, 0.0, 10.0, 10.0],
20771            })
20772            .collect();
20773        diag.note_retained_capture(3, &records);
20774        assert_eq!(diag.slack_top.len(), FILL_DIAG_SLACK_TOP);
20775        // Sorted by slack, worst first, and the two smallest fell off.
20776        assert_eq!(diag.slack_top[0].drawn_px2, 12000.0);
20777        assert_eq!(diag.slack_top[0].slot, 3);
20778        assert_eq!(diag.slack_top[0].shape, 11);
20779        for pair in diag.slack_top.windows(2) {
20780            assert!(pair[0].drawn_px2 - pair[0].lit_px2 >= pair[1].drawn_px2 - pair[1].lit_px2);
20781        }
20782        assert!(diag
20783            .slack_top
20784            .iter()
20785            .all(|entry| entry.drawn_px2 - entry.lit_px2 > 2000.0 - 100.0));
20786    }
20787
20788    #[cfg(not(target_arch = "wasm32"))]
20789    #[test]
20790    fn rim_mesh_band_accepts_only_huge_solid_unclipped_circle_rims() {
20791        let band = rim_mesh_band(&rim_test_shape_data()).expect("circle rim must qualify");
20792        assert_eq!(band.center, [190.0, 190.0]);
20793        assert_eq!(band.inner, 142.0);
20794        assert_eq!(band.outer, 150.0);
20795        assert_eq!(band.start, 0.0);
20796        assert!(
20797            band.sweep >= cranpose_ui_graphics::TAU,
20798            "a rim band is a closed ring"
20799        );
20800        // And it actually meshes through the shared emitter.
20801        let mut vertices = Vec::new();
20802        let mut indices = Vec::new();
20803        emit_arc_band_mesh(
20804            &rim_test_shape_data(),
20805            7,
20806            &band,
20807            &mut vertices,
20808            &mut indices,
20809        )
20810        .expect("rim must mesh");
20811        assert!(vertices.iter().all(|vertex| vertex.shape_idx == 7));
20812
20813        // Rounded SQUARE ring: radius well below the geometry half-extent.
20814        // Meshing it would under-cover the flat spans — the false positive
20815        // the circle gate exists to prevent.
20816        let mut square = rim_test_shape_data();
20817        square.radii = [100.0; 4];
20818        assert!(rim_mesh_band(&square).is_none());
20819
20820        // Non-square box.
20821        let mut oblong = rim_test_shape_data();
20822        oblong.rect = [40.0, 40.0, 300.0, 200.0];
20823        assert!(rim_mesh_band(&oblong).is_none());
20824
20825        // Gradient brush.
20826        let mut gradient = rim_test_shape_data();
20827        gradient.brush_type = 1;
20828        assert!(rim_mesh_band(&gradient).is_none());
20829
20830        // Live clip.
20831        let mut clipped = rim_test_shape_data();
20832        clipped.clip_rect = [0.0, 0.0, 400.0, 400.0];
20833        assert!(rim_mesh_band(&clipped).is_none());
20834
20835        // Small (100 × 100 < 65536 px²), even as a perfect circle.
20836        let mut small = rim_test_shape_data();
20837        small.rect = [40.0, 40.0, 100.0, 100.0];
20838        small.quad01 = [40.0, 40.0, 140.0, 40.0];
20839        small.quad23 = [40.0, 140.0, 140.0, 140.0];
20840        small.radii = [46.0; 4];
20841        assert!(rim_mesh_band(&small).is_none());
20842
20843        // Fill kind, not stroke.
20844        let mut fill = rim_test_shape_data();
20845        fill.stroke_params[1] =
20846            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter);
20847        assert!(rim_mesh_band(&fill).is_none());
20848
20849        // Zero stroke width.
20850        let mut hairline = rim_test_shape_data();
20851        hairline.stroke_params[0] = 0.0;
20852        assert!(rim_mesh_band(&hairline).is_none());
20853
20854        // Mismatched corner radii.
20855        let mut uneven = rim_test_shape_data();
20856        uneven.radii[2] = 145.0;
20857        assert!(rim_mesh_band(&uneven).is_none());
20858    }
20859
20860    #[cfg(not(target_arch = "wasm32"))]
20861    #[test]
20862    fn shape_batch_limits_follow_uniform_binding_size() {
20863        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
20864        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
20865        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
20866        assert_eq!(desktop_shapes, 409);
20867        assert_eq!(
20868            ShapeBatchLimits::desktop(),
20869            ShapeBatchLimits {
20870                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
20871                max_gradient_stops: MAX_GRADIENT_STOPS,
20872                storage: false,
20873            }
20874        );
20875
20876        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
20877        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
20878        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
20879        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
20880        assert_eq!(downlevel.max_shapes_per_batch, 102);
20881        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
20882        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
20883        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
20884
20885        // Degenerate limits must not produce zero-sized buffers.
20886        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
20887        assert_eq!(tiny.max_shapes_per_batch, 1);
20888        assert_eq!(tiny.max_gradient_stops, 1);
20889    }
20890
20891    #[test]
20892    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
20893        // A typical 128 MiB storage binding hits the compile-time ceilings,
20894        // not the device limit: one batch holds the whole scene.
20895        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
20896        assert!(storage.storage);
20897        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
20898        assert_eq!(
20899            storage.max_gradient_stops,
20900            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
20901        );
20902
20903        // The buffers must not be allocated at the multi-megabyte ceiling up
20904        // front; they start small and grow on demand.
20905        assert_eq!(
20906            storage.initial_shape_capacity(),
20907            INITIAL_STORAGE_BATCH_CAPACITY
20908        );
20909        assert_eq!(
20910            storage.initial_gradient_capacity(),
20911            INITIAL_STORAGE_BATCH_CAPACITY
20912        );
20913        assert_eq!(
20914            storage.data_binding_type(),
20915            wgpu::BufferBindingType::Storage { read_only: true }
20916        );
20917        assert!(storage
20918            .data_buffer_usage()
20919            .contains(wgpu::BufferUsages::STORAGE));
20920
20921        // Uniform mode keeps its start-at-the-cap invariant: a uniform
20922        // binding smaller than the shader's fixed array fails validation.
20923        let uniform = ShapeBatchLimits::desktop();
20924        assert_eq!(
20925            uniform.initial_shape_capacity(),
20926            uniform.max_shapes_per_batch
20927        );
20928        assert_eq!(
20929            uniform.initial_gradient_capacity(),
20930            uniform.max_gradient_stops
20931        );
20932        assert_eq!(
20933            uniform.data_binding_type(),
20934            wgpu::BufferBindingType::Uniform
20935        );
20936        assert!(uniform
20937            .data_buffer_usage()
20938            .contains(wgpu::BufferUsages::UNIFORM));
20939    }
20940
20941    #[test]
20942    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
20943        let source = shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20));
20944        assert!(
20945            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
20946            "storage-mode shader must declare a runtime-sized shape array"
20947        );
20948        assert!(
20949            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
20950            "storage-mode shader must declare a runtime-sized gradient array"
20951        );
20952        assert!(
20953            !source.contains("var<uniform> shape_data"),
20954            "the uniform shape declaration must be fully replaced"
20955        );
20956        assert!(
20957            !source.contains("var<uniform> gradient_stops"),
20958            "the uniform gradient declaration must be fully replaced"
20959        );
20960        assert!(
20961            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
20962            "storage-mode shader must declare the retained paint array"
20963        );
20964        assert!(
20965            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
20966            "storage-mode shader must read paint under the paint_select flag"
20967        );
20968        assert!(
20969            source.contains("fn vs_mesh("),
20970            "the storage rewrite must leave the retained-mesh vertex entry intact"
20971        );
20972        assert!(
20973            source.contains("fn vs_shape_instanced("),
20974            "the storage rewrite must leave the instanced-quad vertex entry intact"
20975        );
20976        assert_eq!(
20977            source
20978                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
20979                .count(),
20980            3,
20981            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
20982             the paint_select flag (meshless retained draws ride the instanced \
20983             entry when the selection is latched on)"
20984        );
20985
20986        // The storage variant is what native devices actually compile; it
20987        // must be valid WGSL, not just textually plausible.
20988        let module = naga::front::wgsl::parse_str(&source)
20989            .expect("storage-mode shape shader must parse as WGSL");
20990        naga::valid::Validator::new(
20991            naga::valid::ValidationFlags::all(),
20992            naga::valid::Capabilities::all(),
20993        )
20994        .validate(&module)
20995        .expect("storage-mode shape shader must validate for WebGPU");
20996    }
20997
20998    #[test]
20999    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
21000        // The base text serves WebGL-class uniform devices, which can bind
21001        // no storage buffers: the paint array and its select must exist only
21002        // in the storage-mode rewrite.
21003        for source in [
21004            Cow::Borrowed(shaders::SHADER),
21005            shape_shader_source(ShapeBatchLimits::desktop()),
21006        ] {
21007            assert!(
21008                !source.contains("paint: array"),
21009                "the uniform variant must not declare a paint array"
21010            );
21011            assert!(
21012                source.contains("output.color = shape.color;"),
21013                "the uniform variant must read the color from ShapeData \
21014                 (this literal is also what `shape_shader_source` rewrites)"
21015            );
21016            assert!(
21017                source.contains("paint_select: f32"),
21018                "SimilarityTransform must name the flag field in both \
21019                 variants; the Rust mirror is Pod and uploads raw bytes"
21020            );
21021        }
21022    }
21023
21024    #[test]
21025    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
21026        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
21027        // length is simultaneously the wasm batch cap and the WebGL binding
21028        // size. It must fit the 16 KiB floor exactly.
21029        assert!(
21030            shaders::SHADER.contains("array<ShapeData, 102>"),
21031            "shape.wgsl array length must stay in sync with \
21032             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
21033        );
21034        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
21035        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
21036    }
21037
21038    #[test]
21039    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
21040        // Every overflow buys one doubling, so an app that needs the old fixed
21041        // 4096 atlas reaches it in three resets and then stays there.
21042        assert_eq!(
21043            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
21044            1024
21045        );
21046        assert_eq!(
21047            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
21048            TEXT_GLYPH_ATLAS_MAX_SIZE
21049        );
21050        assert_eq!(
21051            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
21052            TEXT_GLYPH_ATLAS_MAX_SIZE
21053        );
21054
21055        // A device that only grants `downlevel_defaults()`'s 2048 caps the
21056        // growth there rather than failing to create the texture.
21057        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
21058        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
21059
21060        // Never zero and never wrapping, whatever the ceiling turns out to be.
21061        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
21062        assert_eq!(next_glyph_atlas_size(0, 0), 1);
21063    }
21064
21065    #[test]
21066    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
21067        // The atlas grows, so a UV is only meaningful together with the size of
21068        // the texture the entry came from. Reading the size off a constant is
21069        // what would make a grown atlas sample the wrong glyph.
21070        let entry = GlyphAtlasEntry {
21071            x: 128,
21072            y: 256,
21073            width: 16,
21074            height: 32,
21075        };
21076
21077        let small = glyph_atlas_uv_rect(entry, 512);
21078        let large = glyph_atlas_uv_rect(entry, 4096);
21079
21080        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
21081        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
21082        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
21083        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
21084    }
21085
21086    #[test]
21087    fn native_shape_shader_source_uses_native_batch_limits() {
21088        let limits = ShapeBatchLimits::desktop();
21089        let source = shape_shader_source(limits);
21090
21091        assert!(source.contains(&format!(
21092            "array<ShapeData, {}>",
21093            limits.max_shapes_per_batch
21094        )));
21095        assert!(source.contains(&format!(
21096            "array<GradientStop, {}>",
21097            limits.max_gradient_stops
21098        )));
21099        // Sanity: the substitution actually fired rather than silently leaving
21100        // the downlevel literal in place.
21101        assert!(!source.contains("array<ShapeData, 146>"));
21102    }
21103
21104    #[test]
21105    fn stroked_and_arc_shapes_batch_together_with_fills() {
21106        // Strokes and arcs ride the same pipeline, the same ShapeData array and
21107        // the same blend state as fills, so a run of mixed shapes must stay a
21108        // single batch. If they ever split the batch, a polar UI built from
21109        // hundreds of arcs would pay a draw call per arc — precisely the cost
21110        // this primitive exists to remove.
21111        let fill = test_shape(0, BlendMode::SrcOver);
21112        let mut stroked = test_shape(1, BlendMode::SrcOver);
21113        stroked.stroke = Some(
21114            cranpose_ui_graphics::Stroke::new(3.0)
21115                .with_cap(StrokeCap::Round)
21116                .with_join(StrokeJoin::Bevel),
21117        );
21118        let mut arc = test_shape(2, BlendMode::SrcOver);
21119        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
21120            Point::new(4.0, 4.0),
21121            2.0,
21122            4.0,
21123            0.0,
21124            1.0,
21125            StrokeCap::Round,
21126        ));
21127        let trailing_fill = test_shape(3, BlendMode::SrcOver);
21128
21129        assert!(!fill.has_stroke_or_arc());
21130        assert!(stroked.has_stroke_or_arc());
21131        assert!(arc.has_stroke_or_arc());
21132        assert!(!trailing_fill.has_stroke_or_arc());
21133
21134        let shapes = vec![fill, stroked, arc, trailing_fill];
21135        let ordered_items: Vec<_> = (0..shapes.len())
21136            .map(|index| (index, SegmentDrawItem::Shape(index)))
21137            .collect();
21138        let images = Vec::new();
21139
21140        let commands: Vec<_> = SegmentCommandIter::new(
21141            &ordered_items,
21142            &shapes,
21143            &images,
21144            ShapeBatchLimits::desktop(),
21145        )
21146        .collect();
21147
21148        assert_eq!(
21149            commands,
21150            vec![SegmentRenderCommand::DrawChunk(chunk(&[
21151                SegmentBatchPlan::Shape {
21152                    start: 0,
21153                    end: 4,
21154                    blend_mode: BlendMode::SrcOver,
21155                }
21156            ]))],
21157            "mixed fill/stroke/arc runs must stay one batch"
21158        );
21159    }
21160
21161    #[cfg(not(target_arch = "wasm32"))]
21162    #[test]
21163    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
21164        let ordered_items = vec![
21165            (0, SegmentDrawItem::Shape(0)),
21166            (1, SegmentDrawItem::Image(0)),
21167            (2, SegmentDrawItem::Text(0)),
21168            (3, SegmentDrawItem::Shape(1)),
21169        ];
21170        let shapes = vec![
21171            test_shape(0, BlendMode::SrcOver),
21172            test_shape(3, BlendMode::DstOut),
21173        ];
21174        let segment = chunk(&[
21175            SegmentBatchPlan::Shape {
21176                start: 0,
21177                end: 1,
21178                blend_mode: BlendMode::SrcOver,
21179            },
21180            SegmentBatchPlan::Image {
21181                start: 1,
21182                end: 2,
21183                blend_mode: BlendMode::SrcOver,
21184            },
21185            SegmentBatchPlan::Text { start: 2, end: 3 },
21186            SegmentBatchPlan::Shape {
21187                start: 3,
21188                end: 4,
21189                blend_mode: BlendMode::DstOut,
21190            },
21191        ]);
21192
21193        let budget = native_segment_fusion_budget(
21194            &ordered_items,
21195            &shapes,
21196            &[],
21197            &segment,
21198            ShapeBatchLimits::desktop(),
21199        )
21200        .expect("budget should be valid")
21201        .expect("chunk should fit native fusion budget");
21202
21203        assert_eq!(
21204            budget,
21205            NativeSegmentFusionBudget {
21206                shape_count: 2,
21207                gradient_stop_count: 0,
21208            }
21209        );
21210    }
21211
21212    #[cfg(not(target_arch = "wasm32"))]
21213    #[test]
21214    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
21215        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
21216            .map(|index| (index, SegmentDrawItem::Shape(index)))
21217            .collect();
21218        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
21219            .map(|index| test_shape(index, BlendMode::SrcOver))
21220            .collect();
21221        let segment = chunk(&[
21222            SegmentBatchPlan::Shape {
21223                start: 0,
21224                end: MAX_SHAPES_PER_BATCH,
21225                blend_mode: BlendMode::SrcOver,
21226            },
21227            SegmentBatchPlan::Shape {
21228                start: MAX_SHAPES_PER_BATCH,
21229                end: MAX_SHAPES_PER_BATCH + 1,
21230                blend_mode: BlendMode::SrcOver,
21231            },
21232        ]);
21233
21234        let budget = native_segment_fusion_budget(
21235            &ordered_items,
21236            &shapes,
21237            &[],
21238            &segment,
21239            ShapeBatchLimits::desktop(),
21240        )
21241        .expect("valid plan");
21242
21243        assert_eq!(budget, None);
21244    }
21245
21246    #[cfg(not(target_arch = "wasm32"))]
21247    #[test]
21248    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
21249        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
21250        let mut shape = test_shape(0, BlendMode::SrcOver);
21251        let brushes = vec![Brush::linear_gradient(vec![
21252            Color::BLACK;
21253            MAX_GRADIENT_STOPS + 1
21254        ])];
21255        shape.brush = SceneBrush::Gradient(0);
21256        let shapes = vec![shape];
21257        let segment = chunk(&[SegmentBatchPlan::Shape {
21258            start: 0,
21259            end: 1,
21260            blend_mode: BlendMode::SrcOver,
21261        }]);
21262
21263        let budget = native_segment_fusion_budget(
21264            &ordered_items,
21265            &shapes,
21266            &brushes,
21267            &segment,
21268            ShapeBatchLimits::desktop(),
21269        )
21270        .expect("valid plan");
21271
21272        assert_eq!(budget, None);
21273    }
21274
21275    #[cfg(not(target_arch = "wasm32"))]
21276    #[test]
21277    fn native_segment_fusion_partitions_shape_uniform_overflow() {
21278        // The uniform batch cap is derived from the device binding size and
21279        // the 112-byte ShapeData, not from the compile-time ceiling.
21280        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
21281        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
21282            .map(|index| (index, SegmentDrawItem::Shape(index)))
21283            .collect();
21284        let shapes: Vec<_> = (0..=desktop_batch_cap)
21285            .map(|index| test_shape(index, BlendMode::SrcOver))
21286            .collect();
21287        let segment = chunk(&[
21288            SegmentBatchPlan::Shape {
21289                start: 0,
21290                end: desktop_batch_cap,
21291                blend_mode: BlendMode::SrcOver,
21292            },
21293            SegmentBatchPlan::Shape {
21294                start: desktop_batch_cap,
21295                end: desktop_batch_cap + 1,
21296                blend_mode: BlendMode::SrcOver,
21297            },
21298        ]);
21299
21300        let partitions = native_segment_fusion_partitions(
21301            &ordered_items,
21302            &shapes,
21303            &[],
21304            &segment,
21305            ShapeBatchLimits::desktop(),
21306        )
21307        .expect("valid plan")
21308        .expect("overflowing segment should be partitionable");
21309
21310        assert_eq!(partitions.len(), 2);
21311        assert_eq!(
21312            partitions[0],
21313            NativeSegmentFusionPartition {
21314                chunk: chunk(&[SegmentBatchPlan::Shape {
21315                    start: 0,
21316                    end: desktop_batch_cap,
21317                    blend_mode: BlendMode::SrcOver,
21318                }]),
21319                budget: NativeSegmentFusionBudget {
21320                    shape_count: desktop_batch_cap,
21321                    gradient_stop_count: 0,
21322                },
21323            }
21324        );
21325        assert_eq!(
21326            partitions[1],
21327            NativeSegmentFusionPartition {
21328                chunk: chunk(&[SegmentBatchPlan::Shape {
21329                    start: desktop_batch_cap,
21330                    end: desktop_batch_cap + 1,
21331                    blend_mode: BlendMode::SrcOver,
21332                }]),
21333                budget: NativeSegmentFusionBudget {
21334                    shape_count: 1,
21335                    gradient_stop_count: 0,
21336                },
21337            }
21338        );
21339    }
21340
21341    #[cfg(not(target_arch = "wasm32"))]
21342    #[test]
21343    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
21344        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
21345        let ordered_items = vec![
21346            (0, SegmentDrawItem::Shape(0)),
21347            (1, SegmentDrawItem::Shape(1)),
21348            (2, SegmentDrawItem::Shape(2)),
21349        ];
21350        let mut shapes = Vec::new();
21351        let brushes = vec![Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE])];
21352        for index in 0..3 {
21353            let mut shape = test_shape(index, BlendMode::SrcOver);
21354            shape.brush = SceneBrush::Gradient(0);
21355            shapes.push(shape);
21356        }
21357        let segment = chunk(&[SegmentBatchPlan::Shape {
21358            start: 0,
21359            end: 3,
21360            blend_mode: BlendMode::SrcOver,
21361        }]);
21362
21363        let partitions = native_segment_fusion_partitions(
21364            &ordered_items,
21365            &shapes,
21366            &brushes,
21367            &segment,
21368            ShapeBatchLimits::desktop(),
21369        )
21370        .expect("valid plan")
21371        .expect("overflowing gradient segment should be partitionable");
21372
21373        assert_eq!(partitions.len(), 2);
21374        assert_eq!(
21375            partitions[0],
21376            NativeSegmentFusionPartition {
21377                chunk: chunk(&[SegmentBatchPlan::Shape {
21378                    start: 0,
21379                    end: 2,
21380                    blend_mode: BlendMode::SrcOver,
21381                }]),
21382                budget: NativeSegmentFusionBudget {
21383                    shape_count: 2,
21384                    gradient_stop_count: MAX_GRADIENT_STOPS,
21385                },
21386            }
21387        );
21388        assert_eq!(
21389            partitions[1],
21390            NativeSegmentFusionPartition {
21391                chunk: chunk(&[SegmentBatchPlan::Shape {
21392                    start: 2,
21393                    end: 3,
21394                    blend_mode: BlendMode::SrcOver,
21395                }]),
21396                budget: NativeSegmentFusionBudget {
21397                    shape_count: 1,
21398                    gradient_stop_count: STOPS_PER_SHAPE,
21399                },
21400            }
21401        );
21402    }
21403
21404    #[cfg(not(target_arch = "wasm32"))]
21405    #[test]
21406    fn native_segment_fusion_accepts_layer_composite_chunks() {
21407        let ordered_items = vec![
21408            (0, SegmentDrawItem::Shape(0)),
21409            (1, SegmentDrawItem::Composite(0)),
21410            (2, SegmentDrawItem::ShaderComposite(0)),
21411            (3, SegmentDrawItem::Shape(1)),
21412        ];
21413        let shapes = vec![
21414            test_shape(0, BlendMode::SrcOver),
21415            test_shape(1, BlendMode::SrcOver),
21416        ];
21417        let segment = chunk(&[
21418            SegmentBatchPlan::Shape {
21419                start: 0,
21420                end: 1,
21421                blend_mode: BlendMode::SrcOver,
21422            },
21423            SegmentBatchPlan::Composite { start: 1, end: 2 },
21424            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
21425            SegmentBatchPlan::Shape {
21426                start: 3,
21427                end: 4,
21428                blend_mode: BlendMode::SrcOver,
21429            },
21430        ]);
21431
21432        let partitions = native_segment_fusion_partitions(
21433            &ordered_items,
21434            &shapes,
21435            &[],
21436            &segment,
21437            ShapeBatchLimits::desktop(),
21438        )
21439        .expect("valid plan")
21440        .expect("composites are drawable inside the native fused pass");
21441
21442        assert_eq!(
21443            partitions,
21444            vec![NativeSegmentFusionPartition {
21445                chunk: segment,
21446                budget: NativeSegmentFusionBudget {
21447                    shape_count: 2,
21448                    gradient_stop_count: 0,
21449                },
21450            }],
21451            "layer composites and shader composites must preserve order without forcing separate render passes"
21452        );
21453    }
21454
21455    #[cfg(not(target_arch = "wasm32"))]
21456    #[test]
21457    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
21458        // The uniform batch cap is derived from the device binding size and
21459        // the 112-byte ShapeData, not from the compile-time ceiling.
21460        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
21461        let ordered_items: Vec<_> = (0..desktop_batch_cap)
21462            .map(|index| (index, SegmentDrawItem::Shape(index)))
21463            .chain([
21464                (desktop_batch_cap, SegmentDrawItem::Image(0)),
21465                (
21466                    desktop_batch_cap + 1,
21467                    SegmentDrawItem::Shape(desktop_batch_cap),
21468                ),
21469            ])
21470            .collect();
21471        let shapes: Vec<_> = (0..=desktop_batch_cap)
21472            .map(|index| test_shape(index, BlendMode::SrcOver))
21473            .collect();
21474        let segment = chunk(&[
21475            SegmentBatchPlan::Shape {
21476                start: 0,
21477                end: desktop_batch_cap,
21478                blend_mode: BlendMode::SrcOver,
21479            },
21480            SegmentBatchPlan::Image {
21481                start: desktop_batch_cap,
21482                end: desktop_batch_cap + 1,
21483                blend_mode: BlendMode::SrcOver,
21484            },
21485            SegmentBatchPlan::Shape {
21486                start: desktop_batch_cap + 1,
21487                end: desktop_batch_cap + 2,
21488                blend_mode: BlendMode::SrcOver,
21489            },
21490        ]);
21491
21492        let partitions = native_segment_fusion_partitions(
21493            &ordered_items,
21494            &shapes,
21495            &[],
21496            &segment,
21497            ShapeBatchLimits::desktop(),
21498        )
21499        .expect("valid plan")
21500        .expect("overflowing segment should be partitionable");
21501
21502        assert_eq!(partitions.len(), 2);
21503        assert_eq!(
21504            partitions[0].chunk,
21505            chunk(&[
21506                SegmentBatchPlan::Shape {
21507                    start: 0,
21508                    end: desktop_batch_cap,
21509                    blend_mode: BlendMode::SrcOver,
21510                },
21511                SegmentBatchPlan::Image {
21512                    start: desktop_batch_cap,
21513                    end: desktop_batch_cap + 1,
21514                    blend_mode: BlendMode::SrcOver,
21515                },
21516            ])
21517        );
21518        assert_eq!(
21519            partitions[1].chunk,
21520            chunk(&[SegmentBatchPlan::Shape {
21521                start: desktop_batch_cap + 1,
21522                end: desktop_batch_cap + 2,
21523                blend_mode: BlendMode::SrcOver,
21524            }])
21525        );
21526    }
21527
21528    #[test]
21529    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
21530        let ordered_items = vec![
21531            (0, SegmentDrawItem::Shape(0)),
21532            (1, SegmentDrawItem::Image(0)),
21533            (2, SegmentDrawItem::Shape(1)),
21534        ];
21535        let shapes = vec![
21536            test_shape(0, BlendMode::SrcOver),
21537            test_shape(2, BlendMode::DstOut),
21538        ];
21539        let images = vec![test_image(1, BlendMode::SrcOver)];
21540
21541        let commands: Vec<_> = SegmentCommandIter::new(
21542            &ordered_items,
21543            &shapes,
21544            &images,
21545            ShapeBatchLimits::desktop(),
21546        )
21547        .collect();
21548
21549        assert_eq!(
21550            commands,
21551            vec![SegmentRenderCommand::DrawChunk(chunk(&[
21552                SegmentBatchPlan::Shape {
21553                    start: 0,
21554                    end: 1,
21555                    blend_mode: BlendMode::SrcOver,
21556                },
21557                SegmentBatchPlan::Image {
21558                    start: 1,
21559                    end: 2,
21560                    blend_mode: BlendMode::SrcOver,
21561                },
21562                SegmentBatchPlan::Shape {
21563                    start: 2,
21564                    end: 3,
21565                    blend_mode: BlendMode::DstOut,
21566                },
21567            ]))]
21568        );
21569    }
21570
21571    #[test]
21572    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
21573        // The uniform batch cap is derived from the device binding size and
21574        // the 112-byte ShapeData, not from the compile-time ceiling.
21575        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
21576        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
21577            .map(|index| (index, SegmentDrawItem::Shape(index)))
21578            .collect();
21579        let shapes: Vec<_> = (0..=desktop_batch_cap)
21580            .map(|index| test_shape(index, BlendMode::SrcOver))
21581            .collect();
21582        let images = Vec::new();
21583
21584        let commands: Vec<_> = SegmentCommandIter::new(
21585            &ordered_items,
21586            &shapes,
21587            &images,
21588            ShapeBatchLimits::desktop(),
21589        )
21590        .collect();
21591
21592        assert_eq!(
21593            commands,
21594            vec![SegmentRenderCommand::DrawChunk(chunk(&[
21595                SegmentBatchPlan::Shape {
21596                    start: 0,
21597                    end: desktop_batch_cap,
21598                    blend_mode: BlendMode::SrcOver,
21599                },
21600                SegmentBatchPlan::Shape {
21601                    start: desktop_batch_cap,
21602                    end: desktop_batch_cap + 1,
21603                    blend_mode: BlendMode::SrcOver,
21604                },
21605            ]))]
21606        );
21607    }
21608
21609    #[test]
21610    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
21611        let ordered_items = vec![
21612            (0, SegmentDrawItem::Shape(0)),
21613            (1, SegmentDrawItem::Shadow(0)),
21614            (2, SegmentDrawItem::Image(0)),
21615            (3, SegmentDrawItem::Text(0)),
21616        ];
21617        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
21618        let images = vec![test_image(2, BlendMode::SrcOver)];
21619
21620        let commands: Vec<_> = SegmentCommandIter::new(
21621            &ordered_items,
21622            &shapes,
21623            &images,
21624            ShapeBatchLimits::desktop(),
21625        )
21626        .collect();
21627
21628        assert_eq!(
21629            commands,
21630            vec![
21631                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
21632                    start: 0,
21633                    end: 1,
21634                    blend_mode: BlendMode::SrcOver,
21635                }])),
21636                SegmentRenderCommand::Shadow(0),
21637                SegmentRenderCommand::DrawChunk(chunk(&[
21638                    SegmentBatchPlan::Image {
21639                        start: 2,
21640                        end: 3,
21641                        blend_mode: BlendMode::SrcOver,
21642                    },
21643                    SegmentBatchPlan::Text { start: 3, end: 4 },
21644                ])),
21645            ]
21646        );
21647    }
21648
21649    #[test]
21650    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
21651        let mut uploads = StagedBufferUploads::default();
21652        uploads.bytes.extend_from_slice(&[1, 2]);
21653
21654        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
21655
21656        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
21657        assert_eq!(
21658            uploads.copies,
21659            vec![PendingBufferCopy {
21660                source_offset: 4,
21661                target_offset: 0,
21662                size: 4,
21663                target: UploadTarget::ImageIndex,
21664            }]
21665        );
21666    }
21667
21668    #[test]
21669    fn staged_buffer_uploads_ignore_empty_payloads() {
21670        let mut uploads = StagedBufferUploads::default();
21671
21672        uploads.stage(UploadTarget::Uniform, &[]);
21673
21674        assert!(uploads.is_empty());
21675        assert!(uploads.bytes.is_empty());
21676    }
21677
21678    #[test]
21679    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
21680        let mut uploads = StagedBufferUploads::default();
21681        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
21682        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
21683
21684        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
21685        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
21686    }
21687
21688    #[test]
21689    fn staged_buffer_uploads_record_destination_offsets() {
21690        let mut uploads = StagedBufferUploads::default();
21691
21692        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
21693
21694        assert_eq!(uploads.copies[0].target_offset, 256);
21695        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
21696    }
21697
21698    #[test]
21699    fn staged_buffer_uploads_truncate_restores_previous_state() {
21700        let mut uploads = StagedBufferUploads::default();
21701        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
21702        let bytes_len = uploads.bytes.len();
21703        let copies_len = uploads.copies.len();
21704        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
21705
21706        uploads.truncate(bytes_len, copies_len);
21707
21708        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
21709        assert_eq!(uploads.copies.len(), 1);
21710    }
21711
21712    #[test]
21713    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
21714        let mut fill = test_shape(0, BlendMode::SrcOver);
21715        fill.local_rect = Rect {
21716            x: 10.0,
21717            y: 12.0,
21718            width: 40.0,
21719            height: 20.0,
21720        };
21721        fill.shape = Some(RoundedCornerShape::uniform(6.0));
21722
21723        let cutout = test_shape(1, BlendMode::DstOut);
21724        let shadow = test_shadow_draw(vec![
21725            (fill, BlendMode::SrcOver),
21726            (cutout, BlendMode::DstOut),
21727        ]);
21728
21729        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
21730        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
21731        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
21732    }
21733
21734    #[test]
21735    fn inner_shadow_composite_mask_is_none_without_dst_out() {
21736        let fill = test_shape(0, BlendMode::SrcOver);
21737        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
21738        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
21739    }
21740
21741    #[test]
21742    fn render_effect_support_matrix_covers_all_variants() {
21743        let blur = RenderEffect::blur(4.0);
21744        let offset = RenderEffect::offset(2.0, 3.0);
21745        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
21746            r#"
21747            @group(0) @binding(0) var input_texture: texture_2d<f32>;
21748            @group(0) @binding(1) var input_sampler: sampler;
21749            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
21750            struct VertexOutput {
21751                @builtin(position) position: vec4<f32>,
21752                @location(0) uv: vec2<f32>,
21753            }
21754            @vertex
21755            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
21756                var output: VertexOutput;
21757                let x = f32(i32(vertex_index & 1u) * 2 - 1);
21758                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
21759                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
21760                output.position = vec4<f32>(x, y, 0.0, 1.0);
21761                return output;
21762            }
21763            @fragment
21764            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
21765                return textureSample(input_texture, input_sampler, input.uv);
21766            }
21767            "#,
21768        ));
21769        let chain = blur.clone().then(offset.clone());
21770
21771        assert!(is_render_effect_supported(&blur));
21772        assert!(is_render_effect_supported(&offset));
21773        assert!(is_render_effect_supported(&shader));
21774        assert!(is_render_effect_supported(&chain));
21775    }
21776
21777    #[test]
21778    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
21779        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
21780        // All shapes inside the clip_to_bounds container must have a clip set.
21781        let container_local_bounds = Rect {
21782            x: 0.0,
21783            y: 0.0,
21784            width: 800.0,
21785            height: 500.0,
21786        };
21787        // Container is placed at y=50 in parent space via transform_to_parent
21788        let container_clip_in_parent = Rect {
21789            x: 0.0,
21790            y: 50.0,
21791            width: 800.0,
21792            height: 500.0,
21793        };
21794
21795        // Shape that extends above the clip boundary (scroll content scrolled up)
21796        let shape_above = RenderNode::Primitive(PrimitiveEntry {
21797            phase: PrimitivePhase::BeforeChildren,
21798            node: PrimitiveNode::Draw(DrawPrimitiveNode {
21799                primitive: DrawPrimitive::Rect {
21800                    rect: Rect {
21801                        x: 10.0,
21802                        y: -30.0,
21803                        width: 100.0,
21804                        height: 40.0,
21805                    },
21806                    brush: Brush::solid(Color::WHITE),
21807                    stroke: None,
21808                },
21809                clip: None,
21810            }),
21811        });
21812
21813        // Shape within the clip boundary
21814        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
21815            phase: PrimitivePhase::BeforeChildren,
21816            node: PrimitiveNode::Draw(DrawPrimitiveNode {
21817                primitive: DrawPrimitive::Rect {
21818                    rect: Rect {
21819                        x: 10.0,
21820                        y: 100.0,
21821                        width: 100.0,
21822                        height: 40.0,
21823                    },
21824                    brush: Brush::solid(Color::WHITE),
21825                    stroke: None,
21826                },
21827                clip: None,
21828            }),
21829        });
21830
21831        // Shape below the clip boundary (scroll content below viewport)
21832        let shape_below = RenderNode::Primitive(PrimitiveEntry {
21833            phase: PrimitivePhase::BeforeChildren,
21834            node: PrimitiveNode::Draw(DrawPrimitiveNode {
21835                primitive: DrawPrimitive::Rect {
21836                    rect: Rect {
21837                        x: 10.0,
21838                        y: 600.0,
21839                        width: 100.0,
21840                        height: 40.0,
21841                    },
21842                    brush: Brush::solid(Color::WHITE),
21843                    stroke: None,
21844                },
21845                clip: None,
21846            }),
21847        });
21848
21849        // Content child layer (represents scroll content, translated up by scroll offset)
21850        let mut content_layer = test_layer(
21851            Rect {
21852                x: 0.0,
21853                y: 0.0,
21854                width: 800.0,
21855                height: 1000.0,
21856            },
21857            vec![shape_above, shape_inside, shape_below],
21858        );
21859        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
21860        content_layer.translated_content_context = true;
21861
21862        // Clip container (e.g. TabContent with clip_to_bounds)
21863        let mut clip_container = test_layer(
21864            container_local_bounds,
21865            vec![RenderNode::Layer(Box::new(content_layer))],
21866        );
21867        clip_container.clip_to_bounds = true;
21868        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
21869
21870        // Root
21871        let root = test_layer(
21872            Rect {
21873                x: 0.0,
21874                y: 0.0,
21875                width: 800.0,
21876                height: 600.0,
21877            },
21878            vec![RenderNode::Layer(Box::new(clip_container))],
21879        );
21880
21881        let mut rect_cache = HashMap::new();
21882        let mut requirements_cache = HashMap::new();
21883        let collected =
21884            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
21885
21886        assert_eq!(
21887            collected.scene.shapes.len(),
21888            3,
21889            "all three shapes should be flattened into the scene"
21890        );
21891
21892        for (i, shape) in collected.scene.shapes.iter().enumerate() {
21893            assert!(
21894                shape.clip.is_some(),
21895                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
21896                i,
21897                shape.rect
21898            );
21899            let clip = shape.clip.unwrap();
21900            assert_eq!(
21901                clip, container_clip_in_parent,
21902                "shape {} clip should match the clip_to_bounds container bounds in parent space",
21903                i
21904            );
21905        }
21906    }
21907
21908    #[test]
21909    fn clip_to_bounds_culls_child_layers_outside_boundary() {
21910        // Reproduces the out-of-clip rendering bug: a child layer with
21911        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
21912        // entirely below the parent's clip_to_bounds boundary must be culled.
21913        // Before the fix, resolve_clip returned None for non-overlapping rects,
21914        // which downstream code interpreted as "no clipping" instead of "fully clipped",
21915        // causing invisible content to render everywhere.
21916
21917        let clip_container_bounds = Rect {
21918            x: 0.0,
21919            y: 0.0,
21920            width: 800.0,
21921            height: 500.0,
21922        };
21923
21924        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
21925            phase: PrimitivePhase::BeforeChildren,
21926            node: PrimitiveNode::Draw(DrawPrimitiveNode {
21927                primitive: DrawPrimitive::Rect {
21928                    rect: Rect {
21929                        x: 0.0,
21930                        y: 0.0,
21931                        width: 300.0,
21932                        height: 80.0,
21933                    },
21934                    brush: Brush::solid(Color::WHITE),
21935                    stroke: None,
21936                },
21937                clip: None,
21938            }),
21939        });
21940
21941        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
21942        let mut card_outside = crate::test_support::layer_node(
21943            Rect {
21944                x: 0.0,
21945                y: 0.0,
21946                width: 300.0,
21947                height: 80.0,
21948            },
21949            ProjectiveTransform::identity(),
21950            GraphicsLayer {
21951                clip: true,
21952                ..GraphicsLayer::default()
21953            },
21954            vec![shape_in_card.clone()],
21955        );
21956        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
21957
21958        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
21959        let mut card_inside = crate::test_support::layer_node(
21960            Rect {
21961                x: 0.0,
21962                y: 0.0,
21963                width: 300.0,
21964                height: 80.0,
21965            },
21966            ProjectiveTransform::identity(),
21967            GraphicsLayer {
21968                clip: true,
21969                ..GraphicsLayer::default()
21970            },
21971            vec![shape_in_card],
21972        );
21973        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
21974
21975        // Content layer holding both cards
21976        let content = test_layer(
21977            Rect {
21978                x: 0.0,
21979                y: 0.0,
21980                width: 800.0,
21981                height: 1000.0,
21982            },
21983            vec![
21984                RenderNode::Layer(Box::new(card_inside)),
21985                RenderNode::Layer(Box::new(card_outside)),
21986            ],
21987        );
21988
21989        // Clip container
21990        let mut clip_container = test_layer(
21991            clip_container_bounds,
21992            vec![RenderNode::Layer(Box::new(content))],
21993        );
21994        clip_container.clip_to_bounds = true;
21995
21996        // Root
21997        let root = test_layer(
21998            Rect {
21999                x: 0.0,
22000                y: 0.0,
22001                width: 800.0,
22002                height: 600.0,
22003            },
22004            vec![RenderNode::Layer(Box::new(clip_container))],
22005        );
22006
22007        let mut rect_cache = HashMap::new();
22008        let mut requirements_cache = HashMap::new();
22009        let collected =
22010            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
22011
22012        assert_eq!(
22013            collected.scene.shapes.len(),
22014            1,
22015            "only the card inside the clip boundary should produce shapes; \
22016             the card outside must be culled entirely"
22017        );
22018
22019        let shape = &collected.scene.shapes[0];
22020        assert!(
22021            shape.clip.is_some(),
22022            "the visible card's shape must have a clip from clip_to_bounds"
22023        );
22024    }
22025
22026    #[test]
22027    fn flattened_layer_shadow_z_index_is_below_content() {
22028        // Shadow must render behind content. When a child layer with shadow_elevation
22029        // is flattened (no isolation), its shadow z-index must be lower than any
22030        // content z-index so shadow draws render first.
22031        let shape = RenderNode::Primitive(PrimitiveEntry {
22032            phase: PrimitivePhase::BeforeChildren,
22033            node: PrimitiveNode::Draw(DrawPrimitiveNode {
22034                primitive: DrawPrimitive::Rect {
22035                    rect: Rect {
22036                        x: 0.0,
22037                        y: 0.0,
22038                        width: 100.0,
22039                        height: 100.0,
22040                    },
22041                    brush: Brush::solid(Color::WHITE),
22042                    stroke: None,
22043                },
22044                clip: None,
22045            }),
22046        });
22047
22048        let child_bounds = Rect {
22049            x: 0.0,
22050            y: 0.0,
22051            width: 100.0,
22052            height: 100.0,
22053        };
22054
22055        let child = crate::test_support::layer_node(
22056            child_bounds,
22057            ProjectiveTransform::translation(50.0, 50.0),
22058            GraphicsLayer {
22059                shadow_elevation: 20.0,
22060                ..GraphicsLayer::default()
22061            },
22062            vec![shape],
22063        );
22064
22065        let root = test_layer(
22066            Rect {
22067                x: 0.0,
22068                y: 0.0,
22069                width: 800.0,
22070                height: 600.0,
22071            },
22072            vec![RenderNode::Layer(Box::new(child))],
22073        );
22074
22075        let mut rect_cache = HashMap::new();
22076        let mut requirements_cache = HashMap::new();
22077        let collected =
22078            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
22079
22080        assert!(
22081            !collected.scene.shadow_draws.is_empty(),
22082            "shadow_elevation > 0 must produce shadow draws"
22083        );
22084        let max_shadow_z = collected
22085            .scene
22086            .shadow_draws
22087            .iter()
22088            .map(|s| s.z_index)
22089            .max()
22090            .unwrap();
22091        let min_content_z = collected
22092            .scene
22093            .shapes
22094            .iter()
22095            .map(|s| s.z_index)
22096            .min()
22097            .unwrap();
22098        assert!(
22099            max_shadow_z < min_content_z,
22100            "shadow z-index ({}) must be less than content z-index ({}); \
22101             shadows must render behind their content",
22102            max_shadow_z,
22103            min_content_z
22104        );
22105    }
22106
22107    /// One retained bundle op key with the fields the invalidation tests
22108    /// vary; the rest stay representative constants.
22109    #[cfg(not(target_arch = "wasm32"))]
22110    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
22111        RetainedBundleOpKey {
22112            slot,
22113            capture_epoch: epoch,
22114            first,
22115            last,
22116            retained_index: slot,
22117            has_mesh: false,
22118        }
22119    }
22120
22121    #[cfg(not(target_arch = "wasm32"))]
22122    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
22123        RetainedBundleKey { ops: ops.to_vec() }
22124    }
22125
22126    /// The same stretch on consecutive frames reuses its bundle: one
22127    /// rebuild, then cached executes.
22128    #[cfg(not(target_arch = "wasm32"))]
22129    #[test]
22130    fn retained_bundle_cache_reuses_stable_keys() {
22131        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
22132        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
22133        let key = bundle_key(&ops);
22134
22135        assert!(!cache.hit(&key), "empty cache must miss");
22136        cache.insert(key.clone(), 111);
22137        assert_eq!(cache.get(&key), Some(&111));
22138        cache.end_frame();
22139
22140        for _ in 0..3 {
22141            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
22142            cache.end_frame();
22143        }
22144        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
22145    }
22146
22147    /// Recapture (epoch bump), span reorder, count change, range change and
22148    /// slot release each change the key, so a stale bundle can never satisfy
22149    /// the lookup.
22150    #[cfg(not(target_arch = "wasm32"))]
22151    #[test]
22152    fn retained_bundle_cache_invalidates_on_any_op_change() {
22153        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
22154        let variants: [Vec<RetainedBundleOpKey>; 5] = [
22155            // Recaptured slot 3: same id, bumped epoch.
22156            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
22157            // Reordered stretch.
22158            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
22159            // Op count changed.
22160            vec![bundle_op(3, Some(7), 0, 40)],
22161            // Draw range changed.
22162            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
22163            // Slot 5 released: epoch gone.
22164            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
22165        ];
22166        for changed in variants {
22167            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
22168            cache.insert(bundle_key(&ops), 111);
22169            cache.end_frame();
22170            assert!(
22171                !cache.hit(&RetainedBundleKey {
22172                    ops: changed.clone()
22173                }),
22174                "changed key {changed:?} must not reuse the stale bundle"
22175            );
22176        }
22177    }
22178
22179    /// Entries a frame does not use are evicted at its end — bundles pin
22180    /// slot buffers, so unused ones must not accumulate — and `clear` (the
22181    /// slot-release path) empties the cache outright.
22182    #[cfg(not(target_arch = "wasm32"))]
22183    #[test]
22184    fn retained_bundle_cache_evicts_unused_entries() {
22185        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
22186        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
22187        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
22188        cache.insert(stale.clone(), 1);
22189        cache.insert(live.clone(), 2);
22190        cache.end_frame();
22191
22192        assert!(cache.hit(&live));
22193        cache.end_frame();
22194
22195        assert!(
22196            !cache.hit(&stale),
22197            "entry unused for a frame must have been evicted"
22198        );
22199        assert!(cache.hit(&live), "used entry must survive eviction");
22200
22201        cache.clear();
22202        assert!(!cache.hit(&live), "clear must drop every entry");
22203    }
22204}