ailloli_ui_render_wgpu/
frame_plan.rs

1//! Frame render plan (single-pass compositing): **pure CPU**.
2//!
3//! Given a frame's layers + a [`PreparedResources`] snapshot, [`FrameRenderPlan::build_cpu`]
4//! produces:
5//!   - vertex arenas (one [`Vec`] per primitive type, accumulated for the whole frame),
6//!   - planned layers (scissor / stencil_ref / clip params / stencil mask range),
7//!   - planned batches (pipeline + clip_bind + texture + vertex_range),
8//!   - isolated offscreen passes + composite batches (isolated compositor).
9//!
10//! No [`wgpu::Device`] / [`wgpu::Queue`] / [`crate::text::TextAtlas`] /
11//! [`crate::icons::IconCache`] access here. Tests can mock [`PreparedResources`]
12//! and call `build_cpu` without a GPU.
13//!
14//! The plan guarantees:
15//!   - arena ranges are stable for the whole frame (each batch has a `Range<u32>`
16//!     into a buffer that is uploaded **once** via `create_buffer_init`),
17//!   - batch fusion never crosses a layer boundary,
18//!   - batch fusion requires identical `pipeline + clip_bind + texture + adjacency`.
19
20use std::collections::HashSet;
21use std::ops::Range;
22
23use ailloli_ui_core::math::Scale;
24use ailloli_ui_core::{BorderStyle, ClipShape, Color, Point, Radius, Rect};
25use ailloli_ui_runtime::scene::ClipEntry;
26use ailloli_ui_runtime::{
27    BlendMode, DrawBorder, DrawBoxShadow, DrawCmd, DrawImage, DrawPolyline, DrawRRect, DrawRect,
28    DrawRingProgress,
29};
30
31use crate::clip::{ClipParamsGpu, ClipRenderMode};
32use crate::cmd_bounds::{
33    inflate_for_effects, push_composite_quad, scissor_to_local, snap_and_clamp_bounds,
34    union_cmd_bounds_prepared,
35};
36use crate::frame_prep::PreparedResources;
37use crate::icons::IconKey;
38use crate::isolated_budget::IsolatedBudgetPolicy;
39use crate::isolated_plan::{
40    BackdropCapturePoint, CompositeParams, IsolatedEffectChain, OffscreenPassId,
41    PlannedIsolatedComposite, PlannedIsolatedPass,
42};
43use crate::passes::primitives::text_origin_from_baseline;
44use crate::passes::{
45    make_tex_rect_scaled, push_border_rrect_scaled, push_box_shadow_scaled, push_polyline_scaled,
46    push_rect_scaled, push_ring_progress_scaled, push_rrect_scaled, to_ndc,
47};
48use crate::renderer::LayerPass;
49use crate::text::GlyphKey;
50use crate::vertices::{
51    BorderRRectVertex, BoxShadowVertex, RRectVertex, RingProgressVertex, StrokeVertex, TexVertex,
52    Vertex,
53};
54
55/// Which CPU/GPU pipeline a batch targets.
56///
57/// # Examples
58///
59/// ```
60/// use ailloli_ui_render_wgpu::PipelineKind;
61/// assert_eq!(PipelineKind::Rect, PipelineKind::Rect);
62/// ```
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum PipelineKind {
65    /// Solid rectangle triangles.
66    Rect,
67    /// Rounded-rectangle signed-distance quad.
68    RRect,
69    /// Rounded-border signed-distance quad.
70    BorderRRect,
71    /// Paint-only box-shadow signed-distance quad.
72    BoxShadow,
73    /// Circular-progress signed-distance quad.
74    RingProgress,
75    /// Antialiased polyline triangles.
76    Stroke,
77    /// Text, icon, or isolated-composite textured triangles.
78    Textured,
79}
80
81/// Which clip uniform group a batch uses (bind group 0).
82///
83/// `None` means the "no clip" uniform; `Shape` means the layer's primary
84/// rounded-mask uniform (only valid if the layer has one).
85///
86/// # Examples
87///
88/// ```
89/// use ailloli_ui_render_wgpu::ClipBindKind;
90/// assert_ne!(ClipBindKind::None, ClipBindKind::Shape);
91/// ```
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ClipBindKind {
94    /// Bind the inactive clip uniform.
95    None,
96    /// Bind parameters for the layer's primary rounded shape.
97    Shape,
98}
99
100/// Which texture bind group a batch uses (bind group 1).
101///
102/// Strict variants so `PartialEq` natively forbids batch fusion across
103/// different textures.
104///
105/// # Examples
106///
107/// ```
108/// use ailloli_ui_render_wgpu::TextureBindKind;
109/// assert_ne!(TextureBindKind::TextPage(0), TextureBindKind::TextPage(1));
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum TextureBindKind {
113    /// Pipeline does not sample a texture.
114    None,
115    /// Glyph-atlas page index.
116    TextPage(u8),
117    /// Persistent icon-cache texture identified by its exact raster key.
118    IconPage(IconKey),
119}
120
121/// One draw call in the main or isolated plan.
122///
123/// # Examples
124///
125/// ```
126/// use ailloli_ui_render_wgpu::{ClipBindKind, PipelineKind, PlannedBatch, TextureBindKind};
127/// let batch = PlannedBatch::Primitives { pipeline: PipelineKind::Rect,
128///     clip_bind: ClipBindKind::None, texture: TextureBindKind::None,
129///     vertex_range: 0..6 };
130/// assert!(matches!(batch, PlannedBatch::Primitives { vertex_range, .. } if vertex_range == (0..6)));
131/// ```
132#[derive(Debug, Clone, PartialEq)]
133pub enum PlannedBatch {
134    /// One primitive-pipeline draw over a contiguous vertex-arena range.
135    Primitives {
136        /// Vertex format and render pipeline to bind.
137        pipeline: PipelineKind,
138        /// Clip uniform to bind at group zero.
139        clip_bind: ClipBindKind,
140        /// Optional sampled texture identity at group one.
141        texture: TextureBindKind,
142        /// Half-open range into the arena associated with `pipeline`.
143        vertex_range: Range<u32>,
144    },
145    /// Composite of a completed isolated pass into the main framebuffer.
146    IsolatedComposite(PlannedIsolatedComposite),
147}
148
149/// One scene layer's GPU state snapshot inside the frame.
150///
151/// # Examples
152///
153/// ```
154/// use ailloli_ui_render_wgpu::{clip::ClipParamsGpu, ClipRenderMode, PlannedLayer};
155/// let layer = PlannedLayer { scissor: None, clip_mode: ClipRenderMode::Scissor,
156///     stencil_ref: None, stencil_mask_range: None,
157///     clip_params_none: ClipParamsGpu::none(), clip_params_shape: None,
158///     use_clip_alpha_for_content: false, batch_range: 0..0 };
159/// assert!(layer.batch_range.is_empty());
160/// ```
161#[derive(Debug, Clone)]
162pub struct PlannedLayer {
163    /// Physical or local scissor rectangle, depending on plan scope.
164    pub scissor: Option<Rect>,
165    /// Effective clip mode after stencil-capability downgrade.
166    pub clip_mode: ClipRenderMode,
167    /// Nonzero stencil reference allocated for this layer, when applicable.
168    pub stencil_ref: Option<u32>,
169    /// Range into [`FrameRenderPlan::stencil_mask_arena`].
170    pub stencil_mask_range: Option<Range<u32>>,
171    /// Inactive clip uniform always available to primitive batches.
172    pub clip_params_none: ClipParamsGpu,
173    /// Primary rounded-shape uniform, if the layer has one.
174    pub clip_params_shape: Option<ClipParamsGpu>,
175    /// Whether content also evaluates clip alpha for shader masking or stencil AA.
176    pub use_clip_alpha_for_content: bool,
177    /// Range into [`FrameRenderPlan::batches`].
178    pub batch_range: Range<usize>,
179}
180
181/// Legacy alias: use [`PlannedIsolatedPass`].
182///
183/// # Examples
184///
185/// ```
186/// use ailloli_ui_render_wgpu::{IsolatedPass, PlannedIsolatedPass};
187/// fn canonical(value: IsolatedPass) -> PlannedIsolatedPass { value }
188/// ```
189pub type IsolatedPass = PlannedIsolatedPass;
190
191/// Whole-frame plan. Single owner of the per-frame vertex arenas + batches.
192///
193/// # Examples
194///
195/// ```
196/// use ailloli_ui_render_wgpu::FrameRenderPlan;
197/// let plan = FrameRenderPlan::default();
198/// assert!(plan.layers.is_empty() && plan.batches.is_empty());
199/// ```
200#[derive(Debug, Default)]
201pub struct FrameRenderPlan {
202    /// Solid rectangle vertices.
203    pub vertex_arena: Vec<Vertex>,
204    /// Rounded-rectangle vertices.
205    pub rrect_vertex_arena: Vec<RRectVertex>,
206    /// Rounded-border vertices.
207    pub border_vertex_arena: Vec<BorderRRectVertex>,
208    /// Box-shadow vertices.
209    pub shadow_vertex_arena: Vec<BoxShadowVertex>,
210    /// Circular-progress vertices.
211    pub ring_progress_vertex_arena: Vec<RingProgressVertex>,
212    /// Polyline vertices.
213    pub stroke_vertex_arena: Vec<StrokeVertex>,
214    /// Text and image vertices.
215    pub tex_vertex_arena: Vec<TexVertex>,
216    /// Rounded-mask vertices for stencil prepasses.
217    pub stencil_mask_arena: Vec<RRectVertex>,
218    /// Quads for compositing isolated textures into the main pass.
219    pub composite_vertex_arena: Vec<TexVertex>,
220    /// Layer state in input/main-pass order.
221    pub layers: Vec<PlannedLayer>,
222    /// Draw batches referenced by each layer's `batch_range`.
223    pub batches: Vec<PlannedBatch>,
224    /// Offscreen passes scheduled for effects or blending.
225    pub planned_isolated: Vec<PlannedIsolatedPass>,
226    /// Ordered backdrop snapshots (increasing `split_planned_layer_idx`).
227    pub backdrop_captures: Vec<BackdropCapturePoint>,
228    /// Whether any main or isolated layer needs a stencil attachment.
229    pub needs_stencil_attachment: bool,
230}
231
232/// CPU planning error for isolated passes.
233///
234/// # Examples
235///
236/// ```
237/// use ailloli_ui_render_wgpu::FramePlanError;
238/// let error = FramePlanError::EmptyIsolatedBounds { layer_idx: 2 };
239/// assert_eq!(error, FramePlanError::EmptyIsolatedBounds { layer_idx: 2 });
240/// ```
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum FramePlanError {
243    /// A layer requested a depth at or above the exclusive configured maximum.
244    NestedDepthExceeded {
245        /// Requested zero-based depth.
246        depth: u8,
247        /// Exclusive configured depth limit.
248        max: u8,
249    },
250    /// An isolated layer contained no commands with usable bounds.
251    EmptyIsolatedBounds {
252        /// Index in the input layer slice.
253        layer_idx: usize,
254    },
255    /// Scheduling another isolated pass exceeded the per-frame count.
256    TooManyIsolatedPasses {
257        /// Pass count that would result from scheduling the layer.
258        count: u32,
259        /// Inclusive number of passes permitted by configuration.
260        max: u32,
261    },
262    /// Legacy explicit budget error for the named layer.
263    OffscreenBudgetExceeded {
264        /// Index in the input layer slice.
265        layer_idx: usize,
266    },
267}
268
269/// Parent, depth, and identity controls for isolated-pass scheduling.
270struct IsoScheduleParams {
271    /// Parent offscreen pass, or `None` for a main-surface child.
272    parent_id: Option<OffscreenPassId>,
273    /// Zero-based isolated nesting depth of the candidate pass.
274    isolated_depth: u8,
275    /// Whether this pass ultimately composites directly onto the main target.
276    composites_to_main: bool,
277    /// Optional externally assigned pass identity used during recursion.
278    forced_pass_id: Option<OffscreenPassId>,
279}
280
281/// Intersects isolated render bounds with the layer scissor for framebuffer capture.
282fn compute_backdrop_capture_rect(layer: &LayerPass<'_>, render_bounds: Rect) -> Rect {
283    let Some(scissor) = layer.clip_plan.scissor else {
284        return render_bounds;
285    };
286    let x0 = render_bounds.x.max(scissor.x);
287    let y0 = render_bounds.y.max(scissor.y);
288    let x1 = (render_bounds.x + render_bounds.w).min(scissor.x + scissor.w);
289    let y1 = (render_bounds.y + render_bounds.h).min(scissor.y + scissor.h);
290    Rect::new(x0, y0, (x1 - x0).max(1.0), (y1 - y0).max(1.0))
291}
292
293/// Schedules or downgrades destination-aware blend capture for one root pass.
294fn attach_blend_planning(
295    plan: &mut FrameRenderPlan,
296    layer: &LayerPass<'_>,
297    pass_id: OffscreenPassId,
298    render_bounds: Rect,
299    composites_to_main: bool,
300    budget: &mut IsolatedBudgetPolicy,
301) {
302    let requested = layer.effects.blend_mode;
303    let mut effective = requested;
304    let mut needs = false;
305    let mut capture_rect = None;
306
307    if composites_to_main && requested != BlendMode::Normal {
308        let rect = compute_backdrop_capture_rect(layer, render_bounds);
309        let w = rect.w.max(1.0).ceil() as u32;
310        let h = rect.h.max(1.0).ceil() as u32;
311        if budget.can_schedule_blend() && !budget.would_exceed_bytes(w, h, false) {
312            budget.record_blend_scheduled(w, h);
313            needs = true;
314            capture_rect = Some(rect);
315        } else {
316            budget.record_blend_skip();
317            effective = BlendMode::Normal;
318        }
319    }
320
321    if let Some(p) = plan.planned_isolated.iter_mut().find(|p| p.id == pass_id) {
322        p.composite_blend_mode = effective;
323        p.needs_blend_dst_capture = needs;
324        p.blend_capture_rect_px = capture_rect;
325        p.composite.blend_mode = effective;
326    }
327}
328
329/// Schedules or skips a clamped backdrop capture for one root pass.
330fn attach_backdrop_planning(
331    plan: &mut FrameRenderPlan,
332    layer: &LayerPass<'_>,
333    layer_idx: usize,
334    pass_id: OffscreenPassId,
335    render_bounds: Rect,
336    composites_to_main: bool,
337    budget: &mut IsolatedBudgetPolicy,
338) {
339    let mut radius = layer.effects.backdrop_blur_radius_px;
340    let mut needs = false;
341    let mut capture_rect = None;
342
343    if composites_to_main && radius > 0.0 {
344        radius = budget.clamp_backdrop_radius(radius);
345        let rect = compute_backdrop_capture_rect(layer, render_bounds);
346        let w = rect.w.max(1.0).ceil() as u32;
347        let h = rect.h.max(1.0).ceil() as u32;
348        if budget.can_schedule_backdrop() && !budget.would_exceed_bytes(w, h, false) {
349            budget.record_backdrop_scheduled(w, h);
350            let split = plan.layers.len();
351            plan.backdrop_captures.push(BackdropCapturePoint {
352                pass_id,
353                source_layer_idx: layer_idx,
354                capture_rect_px: rect,
355                split_planned_layer_idx: split,
356            });
357            needs = true;
358            capture_rect = Some(rect);
359        } else {
360            budget.record_backdrop_skip();
361        }
362    }
363
364    if let Some(p) = plan.planned_isolated.iter_mut().find(|p| p.id == pass_id) {
365        p.backdrop_blur_radius_px = radius;
366        p.needs_backdrop_capture = needs;
367        p.backdrop_capture_rect_px = capture_rect;
368    }
369}
370
371/// Records a child pass on its already-scheduled parent when that parent exists.
372fn link_child_to_parent(
373    plan: &mut FrameRenderPlan,
374    parent_id: OffscreenPassId,
375    child_id: OffscreenPassId,
376) {
377    if let Some(parent) = plan.planned_isolated.iter_mut().find(|p| p.id == parent_id) {
378        parent.child_pass_ids.push(child_id);
379    }
380}
381
382/// Surface, scale, and optional physical translation used while emitting a layer.
383struct LayerPlanCtx {
384    /// Main target width and height in logical pixels.
385    surface: [f32; 2],
386    /// Logical-to-physical device scale.
387    scale: Scale,
388    /// When set, geometry is translated into local offscreen space.
389    origin_px: Option<[f32; 2]>,
390}
391
392#[allow(clippy::too_many_arguments)]
393/// Plans one isolated layer, its captures, composite, and local subplan metadata.
394///
395/// # Errors
396///
397/// Returns [`FramePlanError::TooManyIsolatedPasses`] when the per-frame pass
398/// budget is exhausted, or [`FramePlanError::EmptyIsolatedBounds`] when the
399/// selected layer has no renderable prepared bounds. A byte-budget refusal is
400/// represented by `Ok(None)`, not an error.
401///
402/// # Panics
403///
404/// Panics if `layer_idx` does not index `layers`; callers derive it from that
405/// same slice.
406fn schedule_isolated_layer(
407    plan: &mut FrameRenderPlan,
408    layers: &[LayerPass<'_>],
409    layer_idx: usize,
410    prepared: &PreparedResources,
411    surface: [f32; 2],
412    scale: Scale,
413    stencil_supported: bool,
414    budget: &mut IsolatedBudgetPolicy,
415    next_iso_id: &mut u16,
416    params: IsoScheduleParams,
417) -> Result<Option<OffscreenPassId>, FramePlanError> {
418    let layer = &layers[layer_idx];
419    if !budget.can_schedule_pass() {
420        return Err(FramePlanError::TooManyIsolatedPasses {
421            count: budget.isolated_pass_count + 1,
422            max: budget.config.max_isolated_passes_per_frame,
423        });
424    }
425
426    let mut effect_chain = IsolatedEffectChain::from_effects(&layer.effects);
427    budget.clamp_blur_chain(&mut effect_chain);
428    let content_bounds = union_cmd_bounds_prepared(layer.cmds, scale, prepared)
429        .ok_or(FramePlanError::EmptyIsolatedBounds { layer_idx })?;
430    let render_bounds = inflate_for_effects(content_bounds, &effect_chain);
431    let render_bounds = budget.clamp_surface_bounds(render_bounds);
432    let (render_bounds, origin, local_size) = snap_and_clamp_bounds(render_bounds, surface);
433
434    let mut clip_mode = layer.clip_plan.clip_mode;
435    if clip_mode == ClipRenderMode::Stencil && !stencil_supported {
436        clip_mode = ClipRenderMode::ShaderMask;
437    }
438    let needs_stencil = clip_mode == ClipRenderMode::Stencil;
439
440    if budget.would_exceed_bytes(local_size[0], local_size[1], needs_stencil) {
441        budget.record_bytes_skip();
442        return Ok(None);
443    }
444
445    budget.record_pass_scheduled(local_size[0], local_size[1], needs_stencil);
446
447    let pass_id = if let Some(id) = params.forced_pass_id {
448        id
449    } else {
450        let id = OffscreenPassId(*next_iso_id);
451        *next_iso_id = next_iso_id.saturating_add(1);
452        id
453    };
454
455    plan.planned_isolated.push(PlannedIsolatedPass {
456        id: pass_id,
457        source_layer_idx: layer_idx,
458        content_bounds_px: content_bounds,
459        render_bounds_px: render_bounds,
460        content_origin_px: origin,
461        local_size_px: local_size,
462        needs_stencil,
463        clear_color: Color::new(0.0, 0.0, 0.0, 0.0),
464        effects: effect_chain,
465        composite: CompositeParams {
466            dest_rect_px: render_bounds,
467            opacity: layer.effects.opacity,
468            blend_mode: layer.effects.blend_mode,
469        },
470        parent_id: params.parent_id,
471        child_pass_ids: Vec::new(),
472        isolated_depth: params.isolated_depth,
473        composites_to_main: params.composites_to_main,
474        backdrop_blur_radius_px: 0.0,
475        backdrop_capture_rect_px: None,
476        needs_backdrop_capture: false,
477        composite_blend_mode: layer.effects.blend_mode,
478        needs_blend_dst_capture: false,
479        blend_capture_rect_px: None,
480    });
481
482    attach_backdrop_planning(
483        plan,
484        layer,
485        layer_idx,
486        pass_id,
487        render_bounds,
488        params.composites_to_main,
489        budget,
490    );
491
492    attach_blend_planning(
493        plan,
494        layer,
495        pass_id,
496        render_bounds,
497        params.composites_to_main,
498        budget,
499    );
500
501    if params.composites_to_main {
502        let tint = [1.0, 1.0, 1.0, layer.effects.opacity.clamp(0.0, 1.0)];
503        let vrange = push_composite_quad(
504            &mut plan.composite_vertex_arena,
505            surface,
506            render_bounds,
507            tint,
508        );
509        let batch_start = plan.batches.len();
510        plan.batches
511            .push(PlannedBatch::IsolatedComposite(PlannedIsolatedComposite {
512                pass_id,
513                dest_rect_px: render_bounds,
514                opacity: layer.effects.opacity,
515                blend_mode: plan
516                    .planned_isolated
517                    .iter()
518                    .find(|p| p.id == pass_id)
519                    .map(|p| p.composite_blend_mode)
520                    .unwrap_or(BlendMode::Normal),
521                needs_dst_capture: plan
522                    .planned_isolated
523                    .iter()
524                    .find(|p| p.id == pass_id)
525                    .map(|p| p.needs_blend_dst_capture)
526                    .unwrap_or(false),
527                dst_capture_rect_px: plan
528                    .planned_isolated
529                    .iter()
530                    .find(|p| p.id == pass_id)
531                    .and_then(|p| p.blend_capture_rect_px),
532                vertex_range: vrange,
533            }));
534        plan.layers.push(PlannedLayer {
535            scissor: Some(render_bounds),
536            clip_mode: ClipRenderMode::Scissor,
537            stencil_ref: None,
538            stencil_mask_range: None,
539            clip_params_none: ClipParamsGpu::none(),
540            clip_params_shape: None,
541            use_clip_alpha_for_content: false,
542            batch_range: batch_start..plan.batches.len(),
543        });
544    }
545
546    Ok(Some(pass_id))
547}
548
549#[allow(clippy::too_many_arguments)]
550/// Flushes a contiguous depth-zero isolated segment into a root pass hierarchy.
551///
552/// # Errors
553///
554/// Propagates [`FramePlanError::TooManyIsolatedPasses`] or
555/// [`FramePlanError::EmptyIsolatedBounds`] from any child or root scheduled by
556/// [`schedule_isolated_layer`].
557///
558/// # Panics
559///
560/// Panics if a segment entry does not index `layers`, or if debug overflow
561/// checks detect that the private pass-ID reservation exceeds `u16`; the caller
562/// maintains both invariants through the configured pass budget.
563fn flush_depth_zero_segment(
564    plan: &mut FrameRenderPlan,
565    layers: &[LayerPass<'_>],
566    segment: &mut Vec<usize>,
567    prepared: &PreparedResources,
568    surface: [f32; 2],
569    scale: Scale,
570    stencil_supported: bool,
571    budget: &mut IsolatedBudgetPolicy,
572    next_iso_id: &mut u16,
573    pass_at_depth: &mut Vec<Option<OffscreenPassId>>,
574) -> Result<(), FramePlanError> {
575    if segment.is_empty() {
576        return Ok(());
577    }
578    if segment.len() == 1 {
579        let layer_idx = segment[0];
580        let depth = layers[layer_idx].isolated_depth;
581        let parent_id = (depth > 0)
582            .then(|| pass_at_depth.get((depth - 1) as usize))
583            .flatten()
584            .copied()
585            .flatten();
586        if let Some(pass_id) = schedule_isolated_layer(
587            plan,
588            layers,
589            layer_idx,
590            prepared,
591            surface,
592            scale,
593            stencil_supported,
594            budget,
595            next_iso_id,
596            IsoScheduleParams {
597                parent_id,
598                isolated_depth: depth,
599                composites_to_main: parent_id.is_none(),
600                forced_pass_id: None,
601            },
602        )? {
603            let d = depth as usize;
604            if pass_at_depth.len() <= d {
605                pass_at_depth.resize(d + 1, None);
606            }
607            pass_at_depth[d] = Some(pass_id);
608            pass_at_depth.truncate(d + 1);
609            if let Some(pid) = parent_id {
610                link_child_to_parent(plan, pid, pass_id);
611            }
612        }
613        segment.clear();
614        return Ok(());
615    }
616
617    let n = segment.len();
618    let root_layer_idx = segment[n - 1];
619    let root_id = OffscreenPassId(*next_iso_id + (n - 1) as u16);
620    let mut child_ids = Vec::with_capacity(n - 1);
621    for (i, &child_layer_idx) in segment.iter().take(n - 1).enumerate() {
622        let child_id = OffscreenPassId(*next_iso_id + i as u16);
623        if schedule_isolated_layer(
624            plan,
625            layers,
626            child_layer_idx,
627            prepared,
628            surface,
629            scale,
630            stencil_supported,
631            budget,
632            next_iso_id,
633            IsoScheduleParams {
634                parent_id: Some(root_id),
635                isolated_depth: 1,
636                composites_to_main: false,
637                forced_pass_id: Some(child_id),
638            },
639        )?
640        .is_some()
641        {
642            child_ids.push(child_id);
643            link_child_to_parent(plan, root_id, child_id);
644        }
645    }
646    if let Some(pass_id) = schedule_isolated_layer(
647        plan,
648        layers,
649        root_layer_idx,
650        prepared,
651        surface,
652        scale,
653        stencil_supported,
654        budget,
655        next_iso_id,
656        IsoScheduleParams {
657            parent_id: None,
658            isolated_depth: 0,
659            composites_to_main: true,
660            forced_pass_id: Some(root_id),
661        },
662    )? {
663        if let Some(root_pass) = plan.planned_isolated.iter_mut().find(|p| p.id == pass_id) {
664            root_pass.child_pass_ids = child_ids;
665        }
666        pass_at_depth.clear();
667        pass_at_depth.push(Some(pass_id));
668    }
669    *next_iso_id = next_iso_id.saturating_add(n as u16);
670    segment.clear();
671    Ok(())
672}
673
674impl FrameRenderPlan {
675    /// Builds the frame plan from layer commands. Pure-CPU.
676    ///
677    /// `prepared` provides the atlas/icon lookup; missing entries cause the
678    /// corresponding glyph/icon to be skipped (same semantics as the legacy
679    /// inline path).
680    ///
681    /// `stencil_supported` is the only hardware/runtime configuration knob
682    /// (still CPU-pure): when `false`, a layer that would normally use
683    /// [`ClipRenderMode::Stencil`] is downgraded to [`ClipRenderMode::ShaderMask`].
684    /// This is the same downgrade that the legacy `render_layer_pass` path did inline.
685    ///
686    /// # Panics
687    ///
688    /// Panics with the [`FramePlanError`] debug representation when isolated
689    /// planning fails. Use [`Self::try_build_cpu`] when failure is recoverable.
690    ///
691    /// # Examples
692    ///
693    /// ```
694    /// use ailloli_ui_core::math::Scale;
695    /// use ailloli_ui_render_wgpu::{FrameRenderPlan, PreparedResources};
696    /// let plan = FrameRenderPlan::build_cpu(&[], &PreparedResources::default(),
697    ///     [800.0, 600.0], Scale::new(1.0), true);
698    /// assert!(plan.layers.is_empty());
699    /// ```
700    pub fn build_cpu(
701        layers: &[LayerPass<'_>],
702        prepared: &PreparedResources,
703        surface: [f32; 2],
704        scale: Scale,
705        stencil_supported: bool,
706    ) -> Self {
707        Self::try_build_cpu(
708            layers,
709            prepared,
710            surface,
711            scale,
712            stencil_supported,
713            &mut IsolatedBudgetPolicy::with_defaults(),
714        )
715        .unwrap_or_else(|e| panic!("FrameRenderPlan::build_cpu: {e:?}"))
716    }
717
718    /// Builds a CPU-only plan using caller-owned per-frame budget state.
719    ///
720    /// The budget is reset before planning. Missing prepared glyphs or icons are
721    /// skipped. Geometry and `surface` are physical after applying `scale`;
722    /// passing nonfinite or nonpositive extents violates renderer invariants.
723    ///
724    /// # Errors
725    ///
726    /// Returns an isolated-depth, empty-bounds, or pass-count error. A pass that
727    /// exceeds only the aggregate byte budget is downgraded/skipped and recorded
728    /// in `budget` rather than returned as an error.
729    ///
730    /// # Examples
731    ///
732    /// ```
733    /// use ailloli_ui_core::math::Scale;
734    /// use ailloli_ui_render_wgpu::{FrameRenderPlan, IsolatedBudgetPolicy, PreparedResources};
735    /// let mut budget = IsolatedBudgetPolicy::with_defaults();
736    /// let plan = FrameRenderPlan::try_build_cpu(&[], &PreparedResources::default(),
737    ///     [1.0, 1.0], Scale::new(1.0), false, &mut budget)?;
738    /// assert!(!plan.needs_stencil_attachment);
739    /// # Ok::<(), ailloli_ui_render_wgpu::FramePlanError>(())
740    /// ```
741    pub fn try_build_cpu(
742        layers: &[LayerPass<'_>],
743        prepared: &PreparedResources,
744        surface: [f32; 2],
745        scale: Scale,
746        stencil_supported: bool,
747        budget: &mut IsolatedBudgetPolicy,
748    ) -> Result<Self, FramePlanError> {
749        budget.reset_frame();
750        let mut plan = FrameRenderPlan::default();
751        let ctx = LayerPlanCtx {
752            surface,
753            scale,
754            origin_px: None,
755        };
756        let scale_100 = (scale.dpr * 100.0).round().clamp(1.0, u16::MAX as f32) as u16;
757
758        let mut stencil_ref_counter: u32 = 0;
759        let mut next_iso_id: u16 = 0;
760        let mut pass_at_depth: Vec<Option<OffscreenPassId>> = Vec::new();
761        let mut depth_zero_segment: Vec<usize> = Vec::new();
762
763        for (layer_idx, layer) in layers.iter().enumerate() {
764            let wants_isolated = layer.isolated && layer.effects.needs_offscreen();
765
766            if layer.isolated && !wants_isolated {
767                flush_depth_zero_segment(
768                    &mut plan,
769                    layers,
770                    &mut depth_zero_segment,
771                    prepared,
772                    surface,
773                    scale,
774                    stencil_supported,
775                    budget,
776                    &mut next_iso_id,
777                    &mut pass_at_depth,
778                )?;
779                // Collapse: render in main pass with this layer's clip stack.
780            } else if wants_isolated {
781                if !budget.nesting_depth_ok(layer.isolated_depth) {
782                    return Err(FramePlanError::NestedDepthExceeded {
783                        depth: layer.isolated_depth,
784                        max: budget.config.max_isolated_nesting_depth,
785                    });
786                }
787
788                if layer.isolated_depth > 0 {
789                    flush_depth_zero_segment(
790                        &mut plan,
791                        layers,
792                        &mut depth_zero_segment,
793                        prepared,
794                        surface,
795                        scale,
796                        stencil_supported,
797                        budget,
798                        &mut next_iso_id,
799                        &mut pass_at_depth,
800                    )?;
801                    let depth = layer.isolated_depth;
802                    let parent_id = (depth > 0)
803                        .then(|| pass_at_depth.get((depth - 1) as usize))
804                        .flatten()
805                        .copied()
806                        .flatten();
807                    if let Some(pass_id) = schedule_isolated_layer(
808                        &mut plan,
809                        layers,
810                        layer_idx,
811                        prepared,
812                        surface,
813                        scale,
814                        stencil_supported,
815                        budget,
816                        &mut next_iso_id,
817                        IsoScheduleParams {
818                            parent_id,
819                            isolated_depth: depth,
820                            composites_to_main: parent_id.is_none(),
821                            forced_pass_id: None,
822                        },
823                    )? {
824                        let d = depth as usize;
825                        if pass_at_depth.len() <= d {
826                            pass_at_depth.resize(d + 1, None);
827                        }
828                        pass_at_depth[d] = Some(pass_id);
829                        pass_at_depth.truncate(d + 1);
830                        if let Some(pid) = parent_id {
831                            link_child_to_parent(&mut plan, pid, pass_id);
832                        }
833                    }
834                    continue;
835                }
836
837                if depth_zero_segment.is_empty() {
838                    let parent_id = pass_at_depth.first().copied().flatten();
839                    match schedule_isolated_layer(
840                        &mut plan,
841                        layers,
842                        layer_idx,
843                        prepared,
844                        surface,
845                        scale,
846                        stencil_supported,
847                        budget,
848                        &mut next_iso_id,
849                        IsoScheduleParams {
850                            parent_id,
851                            isolated_depth: 0,
852                            composites_to_main: parent_id.is_none(),
853                            forced_pass_id: None,
854                        },
855                    )? {
856                        Some(pass_id) => {
857                            pass_at_depth.clear();
858                            pass_at_depth.push(Some(pass_id));
859                            if let Some(pid) = parent_id {
860                                link_child_to_parent(&mut plan, pid, pass_id);
861                            }
862                            continue;
863                        }
864                        None => {
865                            // Bytes budget or collapse: render in main pass below.
866                        }
867                    }
868                } else {
869                    depth_zero_segment.push(layer_idx);
870                    continue;
871                }
872            } else {
873                flush_depth_zero_segment(
874                    &mut plan,
875                    layers,
876                    &mut depth_zero_segment,
877                    prepared,
878                    surface,
879                    scale,
880                    stencil_supported,
881                    budget,
882                    &mut next_iso_id,
883                    &mut pass_at_depth,
884                )?;
885            }
886
887            let mut clip_mode = layer.clip_plan.clip_mode;
888            if clip_mode == ClipRenderMode::Stencil && !stencil_supported {
889                clip_mode = ClipRenderMode::ShaderMask;
890            }
891            let stencil_ref = if clip_mode == ClipRenderMode::Stencil {
892                stencil_ref_counter = stencil_ref_counter.saturating_add(1);
893                if stencil_ref_counter > 255 {
894                    // Pathological: too many stencil layers in one frame.
895                    // Wrap (the GPU will draw garbage past 255 anyway).
896                    stencil_ref_counter = 1;
897                }
898                Some(stencil_ref_counter)
899            } else {
900                None
901            };
902
903            let stencil_mask_range = if clip_mode == ClipRenderMode::Stencil {
904                if let Some(ClipEntry {
905                    shape: ClipShape::RoundRect { rect, radius },
906                    ..
907                }) = layer.clip_plan.primary_round_mask
908                {
909                    let start = plan.stencil_mask_arena.len() as u32;
910                    push_rrect_scaled(
911                        &mut plan.stencil_mask_arena,
912                        surface[0],
913                        surface[1],
914                        scale,
915                        DrawRRect {
916                            rect,
917                            radius,
918                            color: Color::new(1.0, 1.0, 1.0, 1.0),
919                        },
920                    );
921                    let end = plan.stencil_mask_arena.len() as u32;
922                    Some(start..end)
923                } else {
924                    None
925                }
926            } else {
927                None
928            };
929
930            let clip_params_none = ClipParamsGpu::none();
931            let clip_params_shape = layer
932                .clip_plan
933                .primary_round_mask
934                .map(|entry| ClipParamsGpu::from_shape(&entry.shape, scale.dpr));
935            let use_clip_alpha_for_content = matches!(clip_mode, ClipRenderMode::ShaderMask)
936                || (matches!(clip_mode, ClipRenderMode::Stencil)
937                    && crate::clip::stencil_aa_enabled());
938
939            let content_clip_bind = if use_clip_alpha_for_content && clip_params_shape.is_some() {
940                ClipBindKind::Shape
941            } else {
942                ClipBindKind::None
943            };
944
945            let batch_start = plan.batches.len();
946            append_layer_draws(
947                &mut plan,
948                layer,
949                prepared,
950                &ctx,
951                scale_100,
952                content_clip_bind,
953                batch_start,
954            );
955
956            let batch_end = plan.batches.len();
957
958            plan.layers.push(PlannedLayer {
959                scissor: layer.clip_plan.scissor,
960                clip_mode,
961                stencil_ref,
962                stencil_mask_range,
963                clip_params_none,
964                clip_params_shape,
965                use_clip_alpha_for_content,
966                batch_range: batch_start..batch_end,
967            });
968
969            if clip_mode == ClipRenderMode::Stencil {
970                plan.needs_stencil_attachment = true;
971            }
972        }
973
974        flush_depth_zero_segment(
975            &mut plan,
976            layers,
977            &mut depth_zero_segment,
978            prepared,
979            surface,
980            scale,
981            stencil_supported,
982            budget,
983            &mut next_iso_id,
984            &mut pass_at_depth,
985        )?;
986
987        // Silence the borrow-check helper used only when no DrawCmd::Image runs.
988        let _ = HashSet::<IconKey>::new();
989
990        Ok(plan)
991    }
992
993    /// Builds a sub-plan for one isolated layer in local offscreen coordinates.
994    ///
995    /// Geometry is translated by `iso.content_origin_px`; the local surface is
996    /// `iso.local_size_px`. Stencil falls back to a shader mask when unsupported.
997    /// This function plans only the supplied layer's primitive batches and does
998    /// not recursively schedule additional isolated passes.
999    ///
1000    /// # Examples
1001    ///
1002    /// ```no_run
1003    /// use ailloli_ui_core::math::Scale;
1004    /// use ailloli_ui_render_wgpu::{FrameRenderPlan, LayerPass, PlannedIsolatedPass,
1005    ///     PreparedResources};
1006    /// fn subplan(layer: &LayerPass<'_>, isolated: &PlannedIsolatedPass) -> FrameRenderPlan {
1007    ///     FrameRenderPlan::build_isolated_subplan(layer, &PreparedResources::default(),
1008    ///         isolated, Scale::new(1.0), true)
1009    /// }
1010    /// ```
1011    pub fn build_isolated_subplan(
1012        layer: &LayerPass<'_>,
1013        prepared: &PreparedResources,
1014        iso: &PlannedIsolatedPass,
1015        scale: Scale,
1016        stencil_supported: bool,
1017    ) -> Self {
1018        let local_surface = [iso.local_size_px[0] as f32, iso.local_size_px[1] as f32];
1019        let mut plan = FrameRenderPlan::default();
1020        let ctx = LayerPlanCtx {
1021            surface: local_surface,
1022            scale,
1023            origin_px: Some(iso.content_origin_px),
1024        };
1025        let scale_100 = (scale.dpr * 100.0).round().clamp(1.0, u16::MAX as f32) as u16;
1026        let mut clip_mode = layer.clip_plan.clip_mode;
1027        if clip_mode == ClipRenderMode::Stencil && !stencil_supported {
1028            clip_mode = ClipRenderMode::ShaderMask;
1029        }
1030        let stencil_ref = if clip_mode == ClipRenderMode::Stencil {
1031            Some(1u32)
1032        } else {
1033            None
1034        };
1035
1036        let stencil_mask_range = if clip_mode == ClipRenderMode::Stencil {
1037            if let Some(ClipEntry {
1038                shape: ClipShape::RoundRect { rect, radius },
1039                ..
1040            }) = layer.clip_plan.primary_round_mask
1041            {
1042                let rect = translate_rect(rect, iso.content_origin_px);
1043                let start = plan.stencil_mask_arena.len() as u32;
1044                push_rrect_scaled(
1045                    &mut plan.stencil_mask_arena,
1046                    local_surface[0],
1047                    local_surface[1],
1048                    scale,
1049                    DrawRRect {
1050                        rect,
1051                        radius,
1052                        color: Color::new(1.0, 1.0, 1.0, 1.0),
1053                    },
1054                );
1055                let end = plan.stencil_mask_arena.len() as u32;
1056                Some(start..end)
1057            } else {
1058                None
1059            }
1060        } else {
1061            None
1062        };
1063
1064        let clip_params_none = ClipParamsGpu::none();
1065        let clip_params_shape = layer
1066            .clip_plan
1067            .primary_round_mask
1068            .map(|entry| ClipParamsGpu::from_shape(&entry.shape, scale.dpr));
1069        let use_clip_alpha_for_content = matches!(clip_mode, ClipRenderMode::ShaderMask)
1070            || (matches!(clip_mode, ClipRenderMode::Stencil) && crate::clip::stencil_aa_enabled());
1071
1072        let content_clip_bind = if use_clip_alpha_for_content && clip_params_shape.is_some() {
1073            ClipBindKind::Shape
1074        } else {
1075            ClipBindKind::None
1076        };
1077
1078        let batch_start = plan.batches.len();
1079        append_layer_draws(
1080            &mut plan,
1081            layer,
1082            prepared,
1083            &ctx,
1084            scale_100,
1085            content_clip_bind,
1086            batch_start,
1087        );
1088
1089        let local_scissor = scissor_to_local(
1090            layer.clip_plan.scissor,
1091            iso.content_origin_px,
1092            iso.local_size_px,
1093        );
1094
1095        plan.layers.push(PlannedLayer {
1096            scissor: local_scissor,
1097            clip_mode,
1098            stencil_ref,
1099            stencil_mask_range,
1100            clip_params_none,
1101            clip_params_shape,
1102            use_clip_alpha_for_content,
1103            batch_range: batch_start..plan.batches.len(),
1104        });
1105
1106        if clip_mode == ClipRenderMode::Stencil {
1107            plan.needs_stencil_attachment = true;
1108        }
1109        plan
1110    }
1111}
1112
1113/// Translates a physical rectangle into pass-local coordinates.
1114fn translate_rect(r: Rect, origin: [f32; 2]) -> Rect {
1115    Rect::new(r.x - origin[0], r.y - origin[1], r.w, r.h)
1116}
1117
1118/// Optionally translates a solid rectangle command into pass-local coordinates.
1119fn translate_dr(dr: DrawRect, origin: Option<[f32; 2]>) -> DrawRect {
1120    let Some(o) = origin else {
1121        return dr;
1122    };
1123    DrawRect {
1124        rect: translate_rect(dr.rect, o),
1125        color: dr.color,
1126    }
1127}
1128
1129/// Optionally translates a rounded-rectangle command into pass-local coordinates.
1130fn translate_rr(rr: DrawRRect, origin: Option<[f32; 2]>) -> DrawRRect {
1131    let Some(o) = origin else {
1132        return rr;
1133    };
1134    DrawRRect {
1135        rect: translate_rect(rr.rect, o),
1136        radius: rr.radius,
1137        color: rr.color,
1138    }
1139}
1140
1141/// Optionally translates a border command into pass-local coordinates.
1142fn translate_border(border: DrawBorder, origin: Option<[f32; 2]>) -> DrawBorder {
1143    let Some(o) = origin else {
1144        return border;
1145    };
1146    DrawBorder {
1147        rect: translate_rect(border.rect, o),
1148        radius: border.radius,
1149        border: border.border,
1150    }
1151}
1152
1153/// Optionally translates box-shadow source geometry into pass-local coordinates.
1154fn translate_box_shadow(shadow: DrawBoxShadow, origin: Option<[f32; 2]>) -> DrawBoxShadow {
1155    let Some(o) = origin else {
1156        return shadow;
1157    };
1158    DrawBoxShadow {
1159        rect: translate_rect(shadow.rect, o),
1160        radius: shadow.radius,
1161        shadow: shadow.shadow,
1162    }
1163}
1164
1165/// Optionally translates ring-progress geometry into pass-local coordinates.
1166fn translate_ring_progress(ring: DrawRingProgress, origin: Option<[f32; 2]>) -> DrawRingProgress {
1167    let Some(o) = origin else {
1168        return ring;
1169    };
1170    DrawRingProgress {
1171        rect: translate_rect(ring.rect, o),
1172        thickness: ring.thickness,
1173        fraction: ring.fraction,
1174        track_color: ring.track_color,
1175        fill_color: ring.fill_color,
1176        start_angle: ring.start_angle,
1177    }
1178}
1179
1180/// Optionally translates every polyline point into pass-local coordinates.
1181fn translate_polyline(polyline: DrawPolyline, origin: Option<[f32; 2]>) -> DrawPolyline {
1182    let Some(o) = origin else {
1183        return polyline;
1184    };
1185    DrawPolyline {
1186        points: polyline
1187            .points
1188            .into_iter()
1189            .map(|point| Point::new(point.x - o[0], point.y - o[1]))
1190            .collect(),
1191        stroke: polyline.stroke,
1192    }
1193}
1194
1195/// Optionally translates image geometry into pass-local coordinates.
1196fn translate_img(img: DrawImage, origin: Option<[f32; 2]>) -> DrawImage {
1197    let Some(o) = origin else {
1198        return img;
1199    };
1200    let mut img = img;
1201    img.rect = translate_rect(img.rect, o);
1202    img
1203}
1204
1205/// Returns whether all four corner radii compare equal as `f32` values.
1206///
1207/// Positive and negative zero compare equal; any NaN makes the result false.
1208fn radius_is_uniform(radius: Radius) -> bool {
1209    radius.tl == radius.tr && radius.tr == radius.br && radius.br == radius.bl
1210}
1211
1212/// Emits one axis-aligned border side as a solid-rectangle batch.
1213fn push_border_rect_batch(
1214    plan: &mut FrameRenderPlan,
1215    batch_start: usize,
1216    clip_bind: ClipBindKind,
1217    surface: [f32; 2],
1218    scale: Scale,
1219    rect: Rect,
1220    color: Color,
1221) {
1222    if rect.w <= 0.0 || rect.h <= 0.0 || color.a <= 0.0 {
1223        return;
1224    }
1225    let start = plan.vertex_arena.len() as u32;
1226    push_rect_scaled(
1227        &mut plan.vertex_arena,
1228        surface[0],
1229        surface[1],
1230        scale,
1231        rect,
1232        color,
1233    );
1234    let end = plan.vertex_arena.len() as u32;
1235    if end > start {
1236        push_planned_batch(
1237            &mut plan.batches,
1238            batch_start,
1239            PlannedBatch::Primitives {
1240                pipeline: PipelineKind::Rect,
1241                clip_bind,
1242                texture: TextureBindKind::None,
1243                vertex_range: start..end,
1244            },
1245        );
1246    }
1247}
1248
1249/// Emits nonuniform or per-side rectangular border geometry.
1250fn emit_rect_border_batches(
1251    plan: &mut FrameRenderPlan,
1252    batch_start: usize,
1253    clip_bind: ClipBindKind,
1254    surface: [f32; 2],
1255    scale: Scale,
1256    border: DrawBorder,
1257) {
1258    let rect = border.rect;
1259    let w = border.border.layout_widths();
1260    if rect.w <= 0.0 || rect.h <= 0.0 {
1261        return;
1262    }
1263
1264    let top = w.top.min(rect.h).max(0.0);
1265    let bottom = w.bottom.min((rect.h - top).max(0.0)).max(0.0);
1266    let left = w.left.min(rect.w).max(0.0);
1267    let right = w.right.min((rect.w - left).max(0.0)).max(0.0);
1268    let middle_y = rect.y + top;
1269    let middle_h = (rect.h - top - bottom).max(0.0);
1270
1271    push_border_rect_batch(
1272        plan,
1273        batch_start,
1274        clip_bind,
1275        surface,
1276        scale,
1277        Rect::new(rect.x, rect.y, rect.w, top),
1278        border.border.colors.top,
1279    );
1280    push_border_rect_batch(
1281        plan,
1282        batch_start,
1283        clip_bind,
1284        surface,
1285        scale,
1286        Rect::new(rect.x, rect.y + rect.h - bottom, rect.w, bottom),
1287        border.border.colors.bottom,
1288    );
1289    push_border_rect_batch(
1290        plan,
1291        batch_start,
1292        clip_bind,
1293        surface,
1294        scale,
1295        Rect::new(rect.x, middle_y, left, middle_h),
1296        border.border.colors.left,
1297    );
1298    push_border_rect_batch(
1299        plan,
1300        batch_start,
1301        clip_bind,
1302        surface,
1303        scale,
1304        Rect::new(rect.x + rect.w - right, middle_y, right, middle_h),
1305        border.border.colors.right,
1306    );
1307}
1308
1309/// Selects SDF uniform-border emission or per-side rectangle fallback.
1310fn emit_border_batches(
1311    plan: &mut FrameRenderPlan,
1312    batch_start: usize,
1313    clip_bind: ClipBindKind,
1314    surface: [f32; 2],
1315    scale: Scale,
1316    border: DrawBorder,
1317) {
1318    if !border.border.is_visible() || border.border.style != BorderStyle::Solid {
1319        return;
1320    }
1321
1322    if radius_is_uniform(border.radius) && border.radius.tl > 0.0 && border.border.is_uniform() {
1323        let Some(width) = border.border.uniform_width() else {
1324            return;
1325        };
1326        let Some(color) = border.border.uniform_color() else {
1327            return;
1328        };
1329        if width <= 0.0 || color.a <= 0.0 {
1330            return;
1331        }
1332        let start = plan.border_vertex_arena.len() as u32;
1333        push_border_rrect_scaled(
1334            &mut plan.border_vertex_arena,
1335            surface[0],
1336            surface[1],
1337            scale,
1338            border,
1339            width,
1340            color,
1341        );
1342        let end = plan.border_vertex_arena.len() as u32;
1343        if end > start {
1344            push_planned_batch(
1345                &mut plan.batches,
1346                batch_start,
1347                PlannedBatch::Primitives {
1348                    pipeline: PipelineKind::BorderRRect,
1349                    clip_bind,
1350                    texture: TextureBindKind::None,
1351                    vertex_range: start..end,
1352                },
1353            );
1354        }
1355        return;
1356    }
1357
1358    emit_rect_border_batches(plan, batch_start, clip_bind, surface, scale, border);
1359}
1360
1361#[allow(clippy::too_many_arguments)]
1362/// Appends all primitive batches for one layer into shared frame arenas.
1363fn append_layer_draws(
1364    plan: &mut FrameRenderPlan,
1365    layer: &LayerPass<'_>,
1366    prepared: &PreparedResources,
1367    ctx: &LayerPlanCtx,
1368    scale_100: u16,
1369    content_clip_bind: ClipBindKind,
1370    batch_start: usize,
1371) {
1372    let [w, h] = ctx.surface;
1373    let scale = ctx.scale;
1374    let origin = ctx.origin_px;
1375
1376    for cmd in layer.cmds {
1377        match cmd {
1378            DrawCmd::Rect(dr) => {
1379                let dr = translate_dr(*dr, origin);
1380                let start = plan.vertex_arena.len() as u32;
1381                push_rect_scaled(&mut plan.vertex_arena, w, h, scale, dr.rect, dr.color);
1382                let end = plan.vertex_arena.len() as u32;
1383                if end > start {
1384                    push_planned_batch(
1385                        &mut plan.batches,
1386                        batch_start,
1387                        PlannedBatch::Primitives {
1388                            pipeline: PipelineKind::Rect,
1389                            clip_bind: content_clip_bind,
1390                            texture: TextureBindKind::None,
1391                            vertex_range: start..end,
1392                        },
1393                    );
1394                }
1395            }
1396            DrawCmd::RRect(rr) => {
1397                let rr = translate_rr(*rr, origin);
1398                let start = plan.rrect_vertex_arena.len() as u32;
1399                push_rrect_scaled(&mut plan.rrect_vertex_arena, w, h, scale, rr);
1400                let end = plan.rrect_vertex_arena.len() as u32;
1401                if end > start {
1402                    push_planned_batch(
1403                        &mut plan.batches,
1404                        batch_start,
1405                        PlannedBatch::Primitives {
1406                            pipeline: PipelineKind::RRect,
1407                            clip_bind: content_clip_bind,
1408                            texture: TextureBindKind::None,
1409                            vertex_range: start..end,
1410                        },
1411                    );
1412                }
1413            }
1414            DrawCmd::Border(border) => {
1415                let border = translate_border(*border, origin);
1416                emit_border_batches(
1417                    plan,
1418                    batch_start,
1419                    content_clip_bind,
1420                    ctx.surface,
1421                    scale,
1422                    border,
1423                );
1424            }
1425            DrawCmd::BoxShadow(shadow) => {
1426                let shadow = translate_box_shadow(*shadow, origin);
1427                let start = plan.shadow_vertex_arena.len() as u32;
1428                push_box_shadow_scaled(&mut plan.shadow_vertex_arena, w, h, scale, shadow);
1429                let end = plan.shadow_vertex_arena.len() as u32;
1430                if end > start {
1431                    push_planned_batch(
1432                        &mut plan.batches,
1433                        batch_start,
1434                        PlannedBatch::Primitives {
1435                            pipeline: PipelineKind::BoxShadow,
1436                            clip_bind: content_clip_bind,
1437                            texture: TextureBindKind::None,
1438                            vertex_range: start..end,
1439                        },
1440                    );
1441                }
1442            }
1443            DrawCmd::RingProgress(ring) => {
1444                let ring = translate_ring_progress(*ring, origin);
1445                let start = plan.ring_progress_vertex_arena.len() as u32;
1446                push_ring_progress_scaled(&mut plan.ring_progress_vertex_arena, w, h, scale, ring);
1447                let end = plan.ring_progress_vertex_arena.len() as u32;
1448                if end > start {
1449                    push_planned_batch(
1450                        &mut plan.batches,
1451                        batch_start,
1452                        PlannedBatch::Primitives {
1453                            pipeline: PipelineKind::RingProgress,
1454                            clip_bind: content_clip_bind,
1455                            texture: TextureBindKind::None,
1456                            vertex_range: start..end,
1457                        },
1458                    );
1459                }
1460            }
1461            DrawCmd::Polyline(polyline) => {
1462                let polyline = translate_polyline(polyline.clone(), origin);
1463                let start = plan.stroke_vertex_arena.len() as u32;
1464                push_polyline_scaled(&mut plan.stroke_vertex_arena, w, h, scale, &polyline);
1465                let end = plan.stroke_vertex_arena.len() as u32;
1466                if end > start {
1467                    push_planned_batch(
1468                        &mut plan.batches,
1469                        batch_start,
1470                        PlannedBatch::Primitives {
1471                            pipeline: PipelineKind::Stroke,
1472                            clip_bind: content_clip_bind,
1473                            texture: TextureBindKind::None,
1474                            vertex_range: start..end,
1475                        },
1476                    );
1477                }
1478            }
1479            DrawCmd::Text(dt) => {
1480                for rect in dt.decoration_rects(scale.dpr) {
1481                    let rect = origin.map_or(rect, |origin| {
1482                        translate_rect(rect, [origin[0] / scale.dpr, origin[1] / scale.dpr])
1483                    });
1484                    push_border_rect_batch(
1485                        plan,
1486                        batch_start,
1487                        content_clip_bind,
1488                        ctx.surface,
1489                        scale,
1490                        rect,
1491                        dt.color,
1492                    );
1493                }
1494                emit_text_batches_local(
1495                    &mut plan.tex_vertex_arena,
1496                    &mut plan.batches,
1497                    batch_start,
1498                    content_clip_bind,
1499                    prepared,
1500                    w,
1501                    h,
1502                    scale,
1503                    scale_100,
1504                    dt,
1505                    origin,
1506                );
1507            }
1508            DrawCmd::Image(img) => {
1509                let img = translate_img(img.clone(), origin);
1510                let physical_px_size = img.rect.w.max(img.rect.h) * scale.dpr;
1511                let key = IconKey {
1512                    icon: img.icon.clone(),
1513                    px_size: physical_px_size.round().clamp(8.0, 256.0) as u16,
1514                    scale_100,
1515                };
1516                if !prepared.icons.contains(&key) {
1517                    continue;
1518                }
1519                let start = plan.tex_vertex_arena.len() as u32;
1520                plan.tex_vertex_arena
1521                    .extend_from_slice(&make_tex_rect_scaled(w, h, scale, img));
1522                let end = plan.tex_vertex_arena.len() as u32;
1523                if end > start {
1524                    push_planned_batch(
1525                        &mut plan.batches,
1526                        batch_start,
1527                        PlannedBatch::Primitives {
1528                            pipeline: PipelineKind::Textured,
1529                            clip_bind: content_clip_bind,
1530                            texture: TextureBindKind::IconPage(key),
1531                            vertex_range: start..end,
1532                        },
1533                    );
1534                }
1535            }
1536        }
1537    }
1538}
1539
1540#[allow(clippy::too_many_arguments)]
1541#[allow(dead_code)]
1542/// Emits prepared text glyphs and decoration rectangles into page-grouped batches.
1543fn emit_text_batches(
1544    arena: &mut Vec<TexVertex>,
1545    batches: &mut Vec<PlannedBatch>,
1546    layer_batch_start: usize,
1547    clip_bind: ClipBindKind,
1548    prepared: &PreparedResources,
1549    w: f32,
1550    h: f32,
1551    scale: Scale,
1552    scale_100: u16,
1553    dt: &ailloli_ui_runtime::DrawText,
1554) {
1555    let (origin_x, origin_y) = text_origin_from_baseline(dt);
1556    let fallback_color = dt.color;
1557    let mut current_page: Option<u8> = None;
1558    let mut current_start: u32 = arena.len() as u32;
1559
1560    for gi in dt.layout.glyphs() {
1561        let color = gi.color.unwrap_or(fallback_color).to_array();
1562        let physical_px_size = ((gi.px_size as f32) * scale.dpr).round();
1563        let key = GlyphKey {
1564            face_id: gi.face_id,
1565            font_index: gi.font_index,
1566            px_size: physical_px_size.clamp(8.0, 128.0) as u16,
1567            glyph_id: gi.glyph_id,
1568            scale_100,
1569        };
1570        let Some(&(page_idx, g)) = prepared.glyphs.get(&key) else {
1571            continue;
1572        };
1573        if g.size_px[0] <= 0.0 || g.size_px[1] <= 0.0 {
1574            continue;
1575        }
1576
1577        if Some(page_idx) != current_page {
1578            if let Some(p) = current_page {
1579                let end = arena.len() as u32;
1580                if end > current_start {
1581                    push_planned_batch(
1582                        batches,
1583                        layer_batch_start,
1584                        PlannedBatch::Primitives {
1585                            pipeline: PipelineKind::Textured,
1586                            clip_bind,
1587                            texture: TextureBindKind::TextPage(p),
1588                            vertex_range: current_start..end,
1589                        },
1590                    );
1591                }
1592            }
1593            current_page = Some(page_idx);
1594            current_start = arena.len() as u32;
1595        }
1596
1597        let pen_x = (origin_x + gi.x) * scale.dpr;
1598        let pen_y = (origin_y + gi.y) * scale.dpr;
1599        let x0 = (pen_x + g.offset_px[0]).round();
1600        let y0 = (pen_y + g.offset_px[1]).round();
1601        let x1 = x0 + g.size_px[0];
1602        let y1 = y0 + g.size_px[1];
1603
1604        let p0 = to_ndc(w, h, x0, y0);
1605        let p1 = to_ndc(w, h, x1, y0);
1606        let p2 = to_ndc(w, h, x1, y1);
1607        let p3 = to_ndc(w, h, x0, y1);
1608
1609        let uv0 = g.uv_min;
1610        let uv2 = g.uv_max;
1611        let uv1 = [uv2[0], uv0[1]];
1612        let uv3 = [uv0[0], uv2[1]];
1613
1614        arena.push(TexVertex {
1615            pos: p0,
1616            uv: uv0,
1617            tint: color,
1618        });
1619        arena.push(TexVertex {
1620            pos: p1,
1621            uv: uv1,
1622            tint: color,
1623        });
1624        arena.push(TexVertex {
1625            pos: p2,
1626            uv: uv2,
1627            tint: color,
1628        });
1629        arena.push(TexVertex {
1630            pos: p0,
1631            uv: uv0,
1632            tint: color,
1633        });
1634        arena.push(TexVertex {
1635            pos: p2,
1636            uv: uv2,
1637            tint: color,
1638        });
1639        arena.push(TexVertex {
1640            pos: p3,
1641            uv: uv3,
1642            tint: color,
1643        });
1644    }
1645
1646    if let Some(p) = current_page {
1647        let end = arena.len() as u32;
1648        if end > current_start {
1649            push_planned_batch(
1650                batches,
1651                layer_batch_start,
1652                PlannedBatch::Primitives {
1653                    pipeline: PipelineKind::Textured,
1654                    clip_bind,
1655                    texture: TextureBindKind::TextPage(p),
1656                    vertex_range: current_start..end,
1657                },
1658            );
1659        }
1660    }
1661}
1662
1663/// Append `next` to `batches`, fusing with the last batch when:
1664///   - we are still inside the same layer (`batches.len() > layer_batch_start`),
1665///   - pipeline / clip_bind / texture are identical,
1666///   - vertex ranges are adjacent (`prev.end == next.start`).
1667fn push_planned_batch(
1668    batches: &mut Vec<PlannedBatch>,
1669    layer_batch_start: usize,
1670    next: PlannedBatch,
1671) {
1672    let PlannedBatch::Primitives {
1673        pipeline,
1674        clip_bind,
1675        texture,
1676        vertex_range,
1677    } = next
1678    else {
1679        batches.push(next);
1680        return;
1681    };
1682    if batches.len() > layer_batch_start {
1683        if let Some(PlannedBatch::Primitives {
1684            pipeline: lp,
1685            clip_bind: lc,
1686            texture: lt,
1687            vertex_range: ref mut lr,
1688        }) = batches.last_mut()
1689        {
1690            if *lp == pipeline && *lc == clip_bind && *lt == texture && lr.end == vertex_range.start
1691            {
1692                lr.end = vertex_range.end;
1693                return;
1694            }
1695        }
1696    }
1697    batches.push(PlannedBatch::Primitives {
1698        pipeline,
1699        clip_bind,
1700        texture,
1701        vertex_range,
1702    });
1703}
1704
1705#[allow(clippy::too_many_arguments)]
1706/// Emits text batches after translating glyph and decoration geometry to local space.
1707fn emit_text_batches_local(
1708    arena: &mut Vec<TexVertex>,
1709    batches: &mut Vec<PlannedBatch>,
1710    layer_batch_start: usize,
1711    clip_bind: ClipBindKind,
1712    prepared: &PreparedResources,
1713    w: f32,
1714    h: f32,
1715    scale: Scale,
1716    scale_100: u16,
1717    dt: &ailloli_ui_runtime::DrawText,
1718    origin: Option<[f32; 2]>,
1719) {
1720    let (origin_x, origin_y) = text_origin_from_baseline(dt);
1721    let ox = origin.map(|o| o[0]).unwrap_or(0.0);
1722    let oy = origin.map(|o| o[1]).unwrap_or(0.0);
1723    let fallback_color = dt.color;
1724    let mut current_page: Option<u8> = None;
1725    let mut current_start: u32 = arena.len() as u32;
1726
1727    for gi in dt.layout.glyphs() {
1728        let color = gi.color.unwrap_or(fallback_color).to_array();
1729        let physical_px_size = ((gi.px_size as f32) * scale.dpr).round();
1730        let key = GlyphKey {
1731            face_id: gi.face_id,
1732            font_index: gi.font_index,
1733            px_size: physical_px_size.clamp(8.0, 128.0) as u16,
1734            glyph_id: gi.glyph_id,
1735            scale_100,
1736        };
1737        let Some(&(page_idx, g)) = prepared.glyphs.get(&key) else {
1738            continue;
1739        };
1740        if g.size_px[0] <= 0.0 || g.size_px[1] <= 0.0 {
1741            continue;
1742        }
1743
1744        if Some(page_idx) != current_page {
1745            if let Some(p) = current_page {
1746                let end = arena.len() as u32;
1747                if end > current_start {
1748                    push_planned_batch(
1749                        batches,
1750                        layer_batch_start,
1751                        PlannedBatch::Primitives {
1752                            pipeline: PipelineKind::Textured,
1753                            clip_bind,
1754                            texture: TextureBindKind::TextPage(p),
1755                            vertex_range: current_start..end,
1756                        },
1757                    );
1758                }
1759            }
1760            current_page = Some(page_idx);
1761            current_start = arena.len() as u32;
1762        }
1763
1764        let pen_x = (origin_x + gi.x) * scale.dpr - ox;
1765        let pen_y = (origin_y + gi.y) * scale.dpr - oy;
1766        let x0 = (pen_x + g.offset_px[0]).round();
1767        let y0 = (pen_y + g.offset_px[1]).round();
1768        let x1 = x0 + g.size_px[0];
1769        let y1 = y0 + g.size_px[1];
1770
1771        let p0 = to_ndc(w, h, x0, y0);
1772        let p1 = to_ndc(w, h, x1, y0);
1773        let p2 = to_ndc(w, h, x1, y1);
1774        let p3 = to_ndc(w, h, x0, y1);
1775
1776        let uv0 = g.uv_min;
1777        let uv2 = g.uv_max;
1778        let uv1 = [uv2[0], uv0[1]];
1779        let uv3 = [uv0[0], uv2[1]];
1780
1781        arena.push(TexVertex {
1782            pos: p0,
1783            uv: uv0,
1784            tint: color,
1785        });
1786        arena.push(TexVertex {
1787            pos: p1,
1788            uv: uv1,
1789            tint: color,
1790        });
1791        arena.push(TexVertex {
1792            pos: p2,
1793            uv: uv2,
1794            tint: color,
1795        });
1796        arena.push(TexVertex {
1797            pos: p0,
1798            uv: uv0,
1799            tint: color,
1800        });
1801        arena.push(TexVertex {
1802            pos: p2,
1803            uv: uv2,
1804            tint: color,
1805        });
1806        arena.push(TexVertex {
1807            pos: p3,
1808            uv: uv3,
1809            tint: color,
1810        });
1811    }
1812
1813    if let Some(p) = current_page {
1814        let end = arena.len() as u32;
1815        if end > current_start {
1816            push_planned_batch(
1817                batches,
1818                layer_batch_start,
1819                PlannedBatch::Primitives {
1820                    pipeline: PipelineKind::Textured,
1821                    clip_bind,
1822                    texture: TextureBindKind::TextPage(p),
1823                    vertex_range: current_start..end,
1824                },
1825            );
1826        }
1827    }
1828}
1829
1830#[cfg(test)]
1831/// Exercises arena isolation, batch fusion, stencil ordering, effects, budgets,
1832/// nested-pass DAGs, destination capture, and local coordinate remapping.
1833mod tests {
1834    use super::*;
1835    use crate::isolated_budget::IsolatedBudgetPolicy;
1836    use ailloli_ui_core::{
1837        Border, BorderStyle, BoxShadow, EdgeColors, EdgeInsets, FontId, IconId, Point, Radius,
1838        StrokeStyle, TextDecoration, TextStyle,
1839    };
1840    use ailloli_ui_runtime::scene::ClipStackSnapshot;
1841    use ailloli_ui_runtime::BlendMode;
1842    use ailloli_ui_runtime::DrawBorder;
1843    use ailloli_ui_runtime::DrawBoxShadow;
1844    use ailloli_ui_runtime::DrawPolyline;
1845    use ailloli_ui_runtime::DrawRect;
1846    use ailloli_ui_runtime::DrawRingProgress;
1847    use ailloli_ui_runtime::DrawText;
1848    use ailloli_ui_text::{TextLayoutParams, TextSystem, WrapMode};
1849
1850    /// Returns the vertex range shared by primitive and composite test batches.
1851    fn batch_range(b: &PlannedBatch) -> std::ops::Range<u32> {
1852        match b {
1853            PlannedBatch::Primitives { vertex_range, .. } => vertex_range.clone(),
1854            PlannedBatch::IsolatedComposite(c) => c.vertex_range.clone(),
1855        }
1856    }
1857
1858    /// Returns primitive texture identity, mapping composites to `None`.
1859    fn batch_texture(b: &PlannedBatch) -> TextureBindKind {
1860        match b {
1861            PlannedBatch::Primitives { texture, .. } => texture.clone(),
1862            PlannedBatch::IsolatedComposite(_) => TextureBindKind::None,
1863        }
1864    }
1865
1866    /// Returns a primitive pipeline and excludes synthetic composites.
1867    fn batch_pipeline(b: &PlannedBatch) -> Option<PipelineKind> {
1868        match b {
1869            PlannedBatch::Primitives { pipeline, .. } => Some(*pipeline),
1870            PlannedBatch::IsolatedComposite(_) => None,
1871        }
1872    }
1873
1874    /// Creates a clipped or unclipped layer for CPU planning scenarios.
1875    fn make_rect_layer<'a>(cmds: &'a [DrawCmd], clip: Option<ClipShape>) -> LayerPass<'a> {
1876        match clip {
1877            Some(c) => LayerPass::with_clip(cmds, c),
1878            None => LayerPass::new(cmds),
1879        }
1880    }
1881
1882    /// Creates a resource-free preparation snapshot for geometry-only scenarios.
1883    fn empty_prepared() -> PreparedResources {
1884        PreparedResources::default()
1885    }
1886
1887    #[test]
1888    fn underlined_wrapped_text_emits_one_rect_per_visual_line() {
1889        let mut text_system = TextSystem::new();
1890        let style = TextStyle::new(FontId::Ui, 14, Color::WHITE).underline();
1891        let layout = text_system.layout_cached(TextLayoutParams {
1892            text: "one two three four",
1893            style,
1894            max_width: Some(35.0),
1895            wrap_mode: WrapMode::WordOrAnywhere,
1896        });
1897        assert!(layout.lines.len() > 1);
1898        let expected_vertices = layout.lines.len() * 6;
1899        let baseline = layout.lines[0].baseline_y;
1900        let cmds = vec![DrawCmd::Text(DrawText {
1901            pos: [4.0, 4.0 + baseline],
1902            color: style.color,
1903            decoration: TextDecoration::Underline,
1904            layout,
1905        })];
1906        let layers = vec![LayerPass::new(&cmds)];
1907
1908        let plan = FrameRenderPlan::build_cpu(
1909            &layers,
1910            &empty_prepared(),
1911            [160.0, 120.0],
1912            Scale::new(2.0),
1913            true,
1914        );
1915
1916        assert_eq!(plan.vertex_arena.len(), expected_vertices);
1917        assert!(plan
1918            .batches
1919            .iter()
1920            .any(|batch| batch_pipeline(batch) == Some(PipelineKind::Rect)));
1921    }
1922
1923    #[test]
1924    fn arena_ranges_do_not_overlap_across_layers() {
1925        let l1 = vec![
1926            DrawCmd::Rect(DrawRect {
1927                rect: Rect::new(0.0, 0.0, 10.0, 10.0),
1928                color: Color::new(1.0, 0.0, 0.0, 1.0),
1929            }),
1930            DrawCmd::Rect(DrawRect {
1931                rect: Rect::new(20.0, 0.0, 10.0, 10.0),
1932                color: Color::new(0.0, 1.0, 0.0, 1.0),
1933            }),
1934        ];
1935        let l2 = vec![
1936            DrawCmd::Rect(DrawRect {
1937                rect: Rect::new(40.0, 0.0, 10.0, 10.0),
1938                color: Color::new(0.0, 0.0, 1.0, 1.0),
1939            }),
1940            DrawCmd::Rect(DrawRect {
1941                rect: Rect::new(60.0, 0.0, 10.0, 10.0),
1942                color: Color::new(1.0, 1.0, 0.0, 1.0),
1943            }),
1944        ];
1945        let l3 = vec![
1946            DrawCmd::Rect(DrawRect {
1947                rect: Rect::new(80.0, 0.0, 10.0, 10.0),
1948                color: Color::new(0.0, 1.0, 1.0, 1.0),
1949            }),
1950            DrawCmd::Rect(DrawRect {
1951                rect: Rect::new(100.0, 0.0, 10.0, 10.0),
1952                color: Color::new(1.0, 0.0, 1.0, 1.0),
1953            }),
1954        ];
1955        let layers = vec![
1956            make_rect_layer(&l1, None),
1957            make_rect_layer(&l2, None),
1958            make_rect_layer(&l3, None),
1959        ];
1960
1961        let plan = FrameRenderPlan::build_cpu(
1962            &layers,
1963            &empty_prepared(),
1964            [128.0, 128.0],
1965            Scale::new(1.0),
1966            true,
1967        );
1968
1969        // 3 layers × 2 rects × 6 vertices = 36 vertices.
1970        assert_eq!(plan.vertex_arena.len(), 36);
1971        // 3 layers × 1 fused batch each = 3 batches.
1972        assert_eq!(plan.batches.len(), 3);
1973        assert_eq!(plan.layers.len(), 3);
1974
1975        // Disjoint ranges.
1976        assert_eq!(batch_range(&plan.batches[0]), 0..12);
1977        assert_eq!(batch_range(&plan.batches[1]), 12..24);
1978        assert_eq!(batch_range(&plan.batches[2]), 24..36);
1979        assert!(!plan.needs_stencil_attachment);
1980        assert!(plan.planned_isolated.is_empty());
1981    }
1982
1983    #[test]
1984    fn batches_keep_intra_layer_merge_when_compatible() {
1985        let l1 = vec![
1986            DrawCmd::Rect(DrawRect {
1987                rect: Rect::new(0.0, 0.0, 10.0, 10.0),
1988                color: Color::new(1.0, 0.0, 0.0, 1.0),
1989            }),
1990            DrawCmd::Rect(DrawRect {
1991                rect: Rect::new(20.0, 0.0, 10.0, 10.0),
1992                color: Color::new(0.0, 1.0, 0.0, 1.0),
1993            }),
1994        ];
1995        let layers = vec![make_rect_layer(&l1, None)];
1996        let plan = FrameRenderPlan::build_cpu(
1997            &layers,
1998            &empty_prepared(),
1999            [64.0, 64.0],
2000            Scale::new(1.0),
2001            true,
2002        );
2003        assert_eq!(plan.batches.len(), 1);
2004        assert_eq!(batch_range(&plan.batches[0]), 0..12);
2005    }
2006
2007    #[test]
2008    fn rect_border_lowers_to_four_rect_quads() {
2009        let cmds = vec![DrawCmd::Border(DrawBorder {
2010            rect: Rect::new(4.0, 6.0, 30.0, 20.0),
2011            radius: Radius::zero(),
2012            border: Border {
2013                widths: EdgeInsets::new(1.0, 2.0, 3.0, 4.0),
2014                colors: EdgeColors::all(Color::WHITE),
2015                style: BorderStyle::Solid,
2016            },
2017        })];
2018        let layers = vec![make_rect_layer(&cmds, None)];
2019
2020        let plan = FrameRenderPlan::build_cpu(
2021            &layers,
2022            &empty_prepared(),
2023            [64.0, 64.0],
2024            Scale::new(1.0),
2025            true,
2026        );
2027
2028        assert_eq!(plan.vertex_arena.len(), 24);
2029        assert!(plan.border_vertex_arena.is_empty());
2030        assert_eq!(plan.batches.len(), 1);
2031        assert_eq!(batch_pipeline(&plan.batches[0]), Some(PipelineKind::Rect));
2032    }
2033
2034    #[test]
2035    fn rounded_uniform_border_uses_sdf_ring_pipeline() {
2036        let cmds = vec![DrawCmd::Border(DrawBorder {
2037            rect: Rect::new(4.0, 6.0, 30.0, 20.0),
2038            radius: Radius::uniform(8.0),
2039            border: Border::new(2.0, Color::WHITE),
2040        })];
2041        let layers = vec![make_rect_layer(&cmds, None)];
2042
2043        let plan = FrameRenderPlan::build_cpu(
2044            &layers,
2045            &empty_prepared(),
2046            [64.0, 64.0],
2047            Scale::new(1.0),
2048            true,
2049        );
2050
2051        assert!(plan.vertex_arena.is_empty());
2052        assert_eq!(plan.border_vertex_arena.len(), 6);
2053        assert_eq!(plan.batches.len(), 1);
2054        assert_eq!(
2055            batch_pipeline(&plan.batches[0]),
2056            Some(PipelineKind::BorderRRect)
2057        );
2058    }
2059
2060    #[test]
2061    fn box_shadow_uses_shadow_pipeline_and_arena() {
2062        let cmds = vec![DrawCmd::BoxShadow(DrawBoxShadow {
2063            rect: Rect::new(10.0, 12.0, 30.0, 20.0),
2064            radius: Radius::uniform(6.0),
2065            shadow: BoxShadow::new(0.0, 4.0, 8.0, 2.0, Color::BLACK),
2066        })];
2067        let layers = vec![make_rect_layer(&cmds, None)];
2068
2069        let plan = FrameRenderPlan::build_cpu(
2070            &layers,
2071            &empty_prepared(),
2072            [96.0, 96.0],
2073            Scale::new(1.0),
2074            true,
2075        );
2076
2077        assert!(plan.vertex_arena.is_empty());
2078        assert_eq!(plan.shadow_vertex_arena.len(), 6);
2079        assert_eq!(plan.batches.len(), 1);
2080        assert_eq!(
2081            batch_pipeline(&plan.batches[0]),
2082            Some(PipelineKind::BoxShadow)
2083        );
2084    }
2085
2086    #[test]
2087    fn ring_progress_uses_ring_pipeline_and_arena() {
2088        let cmds = vec![DrawCmd::RingProgress(DrawRingProgress {
2089            rect: Rect::new(10.0, 12.0, 40.0, 40.0),
2090            thickness: 6.0,
2091            fraction: 0.66,
2092            track_color: Color::new(0.2, 0.2, 0.2, 1.0),
2093            fill_color: Color::new(1.0, 0.35, 0.0, 1.0),
2094            start_angle: -std::f32::consts::FRAC_PI_2,
2095        })];
2096        let layers = vec![make_rect_layer(&cmds, None)];
2097
2098        let plan = FrameRenderPlan::build_cpu(
2099            &layers,
2100            &empty_prepared(),
2101            [96.0, 96.0],
2102            Scale::new(1.0),
2103            true,
2104        );
2105
2106        assert!(plan.vertex_arena.is_empty());
2107        assert_eq!(plan.ring_progress_vertex_arena.len(), 6);
2108        assert_eq!(plan.batches.len(), 1);
2109        assert_eq!(
2110            batch_pipeline(&plan.batches[0]),
2111            Some(PipelineKind::RingProgress)
2112        );
2113    }
2114
2115    #[test]
2116    fn polyline_under_window_root_stencil_has_mask_and_vertices() {
2117        let cmds = vec![DrawCmd::Polyline(DrawPolyline {
2118            points: vec![Point::new(20.0, 100.0), Point::new(300.0, 100.0)],
2119            stroke: StrokeStyle::new(3.0, Color::WHITE),
2120        })];
2121        let root = ClipShape::RoundRect {
2122            rect: Rect::new(0.0, 0.0, 320.0, 200.0),
2123            radius: 10.0,
2124        };
2125        let layers = vec![LayerPass::with_window_root_clip(&cmds, root)];
2126
2127        let plan = FrameRenderPlan::build_cpu(
2128            &layers,
2129            &empty_prepared(),
2130            [320.0, 200.0],
2131            Scale::new(1.0),
2132            true,
2133        );
2134
2135        assert_eq!(plan.layers.len(), 1);
2136        let layer = &plan.layers[0];
2137        assert_eq!(layer.clip_mode, ClipRenderMode::Stencil);
2138        assert!(layer.stencil_mask_range.is_some());
2139        assert!(!plan.stroke_vertex_arena.is_empty());
2140        assert_eq!(batch_pipeline(&plan.batches[0]), Some(PipelineKind::Stroke));
2141
2142        for v in &plan.stroke_vertex_arena {
2143            assert!(
2144                v.pos[0].is_finite()
2145                    && v.pos[1].is_finite()
2146                    && v.pos[0] >= -1.05
2147                    && v.pos[0] <= 1.05
2148                    && v.pos[1] >= -1.05
2149                    && v.pos[1] <= 1.05,
2150                "stroke vertex ndc out of range: {:?}",
2151                v.pos
2152            );
2153        }
2154    }
2155
2156    #[test]
2157    fn polyline_uses_stroke_pipeline_and_arena() {
2158        let cmds = vec![DrawCmd::Polyline(DrawPolyline {
2159            points: vec![Point::new(10.0, 12.0), Point::new(40.0, 30.0)],
2160            stroke: StrokeStyle::new(3.0, Color::WHITE),
2161        })];
2162        let layers = vec![make_rect_layer(&cmds, None)];
2163
2164        let plan = FrameRenderPlan::build_cpu(
2165            &layers,
2166            &empty_prepared(),
2167            [96.0, 96.0],
2168            Scale::new(1.0),
2169            true,
2170        );
2171
2172        assert!(plan.vertex_arena.is_empty());
2173        assert!(!plan.stroke_vertex_arena.is_empty());
2174        assert_eq!(plan.batches.len(), 1);
2175        assert_eq!(batch_pipeline(&plan.batches[0]), Some(PipelineKind::Stroke));
2176    }
2177
2178    #[test]
2179    fn batches_do_not_merge_across_layers() {
2180        let l1 = vec![DrawCmd::Rect(DrawRect {
2181            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2182            color: Color::new(1.0, 0.0, 0.0, 1.0),
2183        })];
2184        let l2 = vec![DrawCmd::Rect(DrawRect {
2185            rect: Rect::new(20.0, 0.0, 10.0, 10.0),
2186            color: Color::new(0.0, 1.0, 0.0, 1.0),
2187        })];
2188        let layers = vec![make_rect_layer(&l1, None), make_rect_layer(&l2, None)];
2189        let plan = FrameRenderPlan::build_cpu(
2190            &layers,
2191            &empty_prepared(),
2192            [64.0, 64.0],
2193            Scale::new(1.0),
2194            true,
2195        );
2196        assert_eq!(plan.batches.len(), 2);
2197        assert_eq!(batch_range(&plan.batches[0]), 0..6);
2198        assert_eq!(batch_range(&plan.batches[1]), 6..12);
2199    }
2200
2201    #[test]
2202    fn batches_do_not_merge_when_texture_differs() {
2203        let mut batches: Vec<PlannedBatch> = Vec::new();
2204        push_planned_batch(
2205            &mut batches,
2206            0,
2207            PlannedBatch::Primitives {
2208                pipeline: PipelineKind::Textured,
2209                clip_bind: ClipBindKind::None,
2210                texture: TextureBindKind::TextPage(0),
2211                vertex_range: 0..6,
2212            },
2213        );
2214        push_planned_batch(
2215            &mut batches,
2216            0,
2217            PlannedBatch::Primitives {
2218                pipeline: PipelineKind::Textured,
2219                clip_bind: ClipBindKind::None,
2220                texture: TextureBindKind::TextPage(1),
2221                vertex_range: 6..12,
2222            },
2223        );
2224        assert_eq!(batches.len(), 2);
2225        assert_eq!(batch_texture(&batches[0]), TextureBindKind::TextPage(0));
2226        assert_eq!(batch_texture(&batches[1]), TextureBindKind::TextPage(1));
2227    }
2228
2229    #[test]
2230    fn batches_do_not_merge_when_icon_differs() {
2231        let mut batches: Vec<PlannedBatch> = Vec::new();
2232        let k1 = IconKey {
2233            icon: IconId::Plus,
2234            px_size: 16,
2235            scale_100: 100,
2236        };
2237        let k2 = IconKey {
2238            icon: IconId::Trash,
2239            px_size: 16,
2240            scale_100: 100,
2241        };
2242        push_planned_batch(
2243            &mut batches,
2244            0,
2245            PlannedBatch::Primitives {
2246                pipeline: PipelineKind::Textured,
2247                clip_bind: ClipBindKind::None,
2248                texture: TextureBindKind::IconPage(k1.clone()),
2249                vertex_range: 0..6,
2250            },
2251        );
2252        push_planned_batch(
2253            &mut batches,
2254            0,
2255            PlannedBatch::Primitives {
2256                pipeline: PipelineKind::Textured,
2257                clip_bind: ClipBindKind::None,
2258                texture: TextureBindKind::IconPage(k2.clone()),
2259                vertex_range: 6..12,
2260            },
2261        );
2262        assert_eq!(batches.len(), 2);
2263        assert_eq!(batch_texture(&batches[0]), TextureBindKind::IconPage(k1));
2264        assert_eq!(batch_texture(&batches[1]), TextureBindKind::IconPage(k2));
2265    }
2266
2267    #[test]
2268    fn intra_layer_fusion_does_not_cross_layer_boundary_via_layer_batch_start() {
2269        let mut batches: Vec<PlannedBatch> = Vec::new();
2270        // layer A:
2271        push_planned_batch(
2272            &mut batches,
2273            0,
2274            PlannedBatch::Primitives {
2275                pipeline: PipelineKind::Rect,
2276                clip_bind: ClipBindKind::None,
2277                texture: TextureBindKind::None,
2278                vertex_range: 0..6,
2279            },
2280        );
2281        // layer B starts at index 1 (one batch already pushed).
2282        let layer_b_start = batches.len();
2283        // Adjacent range, same pipeline / clip / texture, but new layer:
2284        push_planned_batch(
2285            &mut batches,
2286            layer_b_start,
2287            PlannedBatch::Primitives {
2288                pipeline: PipelineKind::Rect,
2289                clip_bind: ClipBindKind::None,
2290                texture: TextureBindKind::None,
2291                vertex_range: 6..12,
2292            },
2293        );
2294        assert_eq!(
2295            batches.len(),
2296            2,
2297            "fusion must not cross layer boundary (layer_batch_start)"
2298        );
2299    }
2300
2301    #[test]
2302    fn stencil_mask_emitted_before_content_for_layer() {
2303        let l1 = vec![DrawCmd::Rect(DrawRect {
2304            rect: Rect::new(0.0, 0.0, 100.0, 100.0),
2305            color: Color::new(0.5, 0.5, 0.5, 1.0),
2306        })];
2307        let root_round = ClipShape::RoundRect {
2308            rect: Rect::new(0.0, 0.0, 100.0, 100.0),
2309            radius: 20.0,
2310        };
2311        // Window-root round → stencil mode.
2312        let snap = ClipStackSnapshot::from_clip(Some(root_round), true);
2313        let layers = vec![LayerPass::with_clip_stack(&l1, snap)];
2314
2315        let plan = FrameRenderPlan::build_cpu(
2316            &layers,
2317            &empty_prepared(),
2318            [128.0, 128.0],
2319            Scale::new(1.0),
2320            true,
2321        );
2322
2323        assert_eq!(plan.layers.len(), 1);
2324        let layer = &plan.layers[0];
2325        assert_eq!(layer.clip_mode, ClipRenderMode::Stencil);
2326        assert!(layer.stencil_ref.is_some());
2327        let mask_range = layer
2328            .stencil_mask_range
2329            .as_ref()
2330            .expect("stencil mask range must be present");
2331        // Mask vertices live in the dedicated stencil_mask_arena (range 0..6).
2332        assert_eq!(*mask_range, 0..6);
2333        assert_eq!(plan.stencil_mask_arena.len(), 6);
2334        assert!(plan.needs_stencil_attachment);
2335    }
2336
2337    #[test]
2338    fn isolated_pass_segmentation_splits_layer_run() {
2339        let l1 = vec![DrawCmd::Rect(DrawRect {
2340            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2341            color: Color::new(1.0, 0.0, 0.0, 1.0),
2342        })];
2343        let l2 = vec![DrawCmd::Rect(DrawRect {
2344            rect: Rect::new(20.0, 0.0, 10.0, 10.0),
2345            color: Color::new(0.0, 1.0, 0.0, 1.0),
2346        })];
2347        let l3 = vec![DrawCmd::Rect(DrawRect {
2348            rect: Rect::new(40.0, 0.0, 10.0, 10.0),
2349            color: Color::new(0.0, 0.0, 1.0, 1.0),
2350        })];
2351        let layers = vec![
2352            LayerPass::new(&l1),
2353            LayerPass::new_isolated(&l2),
2354            LayerPass::new(&l3),
2355        ];
2356
2357        let plan = FrameRenderPlan::build_cpu(
2358            &layers,
2359            &empty_prepared(),
2360            [128.0, 128.0],
2361            Scale::new(1.0),
2362            true,
2363        );
2364
2365        // Two non-isolated layers; isolated without effects collapses to main pass.
2366        assert_eq!(plan.layers.len(), 3);
2367        assert!(plan.planned_isolated.is_empty());
2368    }
2369
2370    #[test]
2371    fn isolated_with_opacity_creates_planned_isolated_and_composite() {
2372        let l1 = vec![DrawCmd::Rect(DrawRect {
2373            rect: Rect::new(0.0, 0.0, 40.0, 40.0),
2374            color: Color::new(1.0, 0.0, 0.0, 1.0),
2375        })];
2376        let mut layer = LayerPass::new_isolated(&l1);
2377        layer.effects.opacity = 0.5;
2378        let plan = FrameRenderPlan::build_cpu(
2379            &[LayerPass::new(&[]), layer],
2380            &empty_prepared(),
2381            [128.0, 128.0],
2382            Scale::new(1.0),
2383            true,
2384        );
2385        assert_eq!(plan.planned_isolated.len(), 1);
2386        assert_eq!(plan.planned_isolated[0].source_layer_idx, 1);
2387        assert!(matches!(
2388            plan.batches.last(),
2389            Some(PlannedBatch::IsolatedComposite(_))
2390        ));
2391    }
2392
2393    #[test]
2394    fn backdrop_root_schedules_capture_split() {
2395        let bg = vec![DrawCmd::Rect(DrawRect {
2396            rect: Rect::new(0.0, 0.0, 128.0, 128.0),
2397            color: Color::new(0.0, 0.0, 1.0, 1.0),
2398        })];
2399        let iso = vec![DrawCmd::Rect(DrawRect {
2400            rect: Rect::new(32.0, 32.0, 64.0, 64.0),
2401            color: Color::new(1.0, 0.0, 0.0, 1.0),
2402        })];
2403        let mut layer = LayerPass::new_isolated(&iso);
2404        layer.effects.opacity = 0.9;
2405        layer.effects.backdrop_blur_radius_px = 16.0;
2406        let top = vec![DrawCmd::Rect(DrawRect {
2407            rect: Rect::new(0.0, 0.0, 8.0, 8.0),
2408            color: Color::new(1.0, 1.0, 1.0, 1.0),
2409        })];
2410        let mut budget = IsolatedBudgetPolicy::with_defaults();
2411        let plan = FrameRenderPlan::try_build_cpu(
2412            &[LayerPass::new(&bg), layer, LayerPass::new(&top)],
2413            &empty_prepared(),
2414            [128.0, 128.0],
2415            Scale::new(1.0),
2416            true,
2417            &mut budget,
2418        )
2419        .expect("backdrop plan");
2420        assert_eq!(plan.backdrop_captures.len(), 1);
2421        assert_eq!(plan.backdrop_captures[0].split_planned_layer_idx, 1);
2422        let iso_pass = &plan.planned_isolated[0];
2423        assert!(iso_pass.needs_backdrop_capture);
2424        assert!(iso_pass.backdrop_capture_rect_px.is_some());
2425        assert!(iso_pass.backdrop_blur_radius_px > 0.0);
2426    }
2427
2428    #[test]
2429    fn backdrop_budget_skip_downgrades() {
2430        let bg = vec![DrawCmd::Rect(DrawRect {
2431            rect: Rect::new(0.0, 0.0, 128.0, 128.0),
2432            color: Color::new(0.0, 0.0, 1.0, 1.0),
2433        })];
2434        let iso = vec![DrawCmd::Rect(DrawRect {
2435            rect: Rect::new(0.0, 0.0, 128.0, 128.0),
2436            color: Color::new(1.0, 0.0, 0.0, 1.0),
2437        })];
2438        let mut layer = LayerPass::new_isolated(&iso);
2439        layer.effects.backdrop_blur_radius_px = 8.0;
2440        layer.effects.opacity = 0.5;
2441        let mut budget = IsolatedBudgetPolicy::with_defaults();
2442        budget.config.max_backdrop_captures_per_frame = 0;
2443        let plan = FrameRenderPlan::try_build_cpu(
2444            &[LayerPass::new(&bg), layer],
2445            &empty_prepared(),
2446            [128.0, 128.0],
2447            Scale::new(1.0),
2448            true,
2449            &mut budget,
2450        )
2451        .expect("plan with backdrop budget skip");
2452        assert!(plan.backdrop_captures.is_empty());
2453        let iso_pass = &plan.planned_isolated[0];
2454        assert!(!iso_pass.needs_backdrop_capture);
2455        assert_eq!(budget.downgrades.backdrop_budget_skipped, 1);
2456    }
2457
2458    #[test]
2459    fn nested_depth_exceeded() {
2460        let cmds = vec![DrawCmd::Rect(DrawRect {
2461            rect: Rect::new(0.0, 0.0, 8.0, 8.0),
2462            color: Color::new(1.0, 0.0, 0.0, 1.0),
2463        })];
2464        let mut layers: Vec<LayerPass<'_>> = Vec::new();
2465        for depth in 0..4u8 {
2466            let mut l = LayerPass::new_isolated(&cmds);
2467            l.isolated_depth = depth;
2468            l.effects.opacity = 0.9;
2469            layers.push(l);
2470        }
2471        let mut budget = IsolatedBudgetPolicy::with_defaults();
2472        budget.config.max_isolated_nesting_depth = 3;
2473        let err = FrameRenderPlan::try_build_cpu(
2474            &layers,
2475            &empty_prepared(),
2476            [64.0, 64.0],
2477            Scale::new(1.0),
2478            true,
2479            &mut budget,
2480        )
2481        .unwrap_err();
2482        assert!(matches!(
2483            err,
2484            FramePlanError::NestedDepthExceeded { depth: 3, max: 3 }
2485        ));
2486    }
2487
2488    #[test]
2489    fn nested_isolated_builds_dag() {
2490        let l1 = vec![DrawCmd::Rect(DrawRect {
2491            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2492            color: Color::new(1.0, 0.0, 0.0, 1.0),
2493        })];
2494        let l2 = vec![DrawCmd::Rect(DrawRect {
2495            rect: Rect::new(20.0, 0.0, 10.0, 10.0),
2496            color: Color::new(0.0, 1.0, 0.0, 1.0),
2497        })];
2498        let mut a = LayerPass::new_isolated(&l1);
2499        a.effects.opacity = 0.5;
2500        let mut b = LayerPass::new_isolated(&l2);
2501        b.effects.opacity = 0.5;
2502        let mut budget = IsolatedBudgetPolicy::with_defaults();
2503        let plan = FrameRenderPlan::try_build_cpu(
2504            &[a, b],
2505            &empty_prepared(),
2506            [64.0, 64.0],
2507            Scale::new(1.0),
2508            true,
2509            &mut budget,
2510        )
2511        .expect("nested consecutive isolated should plan");
2512        assert_eq!(plan.planned_isolated.len(), 2);
2513        let root = plan
2514            .planned_isolated
2515            .iter()
2516            .find(|p| p.composites_to_main)
2517            .expect("root pass");
2518        let child = plan
2519            .planned_isolated
2520            .iter()
2521            .find(|p| !p.composites_to_main)
2522            .expect("child pass");
2523        assert_eq!(root.child_pass_ids, vec![child.id]);
2524        assert_eq!(child.parent_id, Some(root.id));
2525        assert_eq!(
2526            plan.batches
2527                .iter()
2528                .filter(|b| matches!(b, PlannedBatch::IsolatedComposite(_)))
2529                .count(),
2530            1
2531        );
2532    }
2533
2534    #[test]
2535    fn blur_radius_clamped_to_max() {
2536        let l1 = vec![DrawCmd::Rect(DrawRect {
2537            rect: Rect::new(0.0, 0.0, 40.0, 40.0),
2538            color: Color::new(1.0, 0.0, 0.0, 1.0),
2539        })];
2540        let mut layer = LayerPass::new_isolated(&l1);
2541        layer.effects.blur_radius_px = 200.0;
2542        let mut budget = IsolatedBudgetPolicy::with_defaults();
2543        budget.config.max_blur_radius_px = 16.0;
2544        let plan = FrameRenderPlan::try_build_cpu(
2545            &[layer],
2546            &empty_prepared(),
2547            [128.0, 128.0],
2548            Scale::new(1.0),
2549            true,
2550            &mut budget,
2551        )
2552        .unwrap();
2553        let blur = plan.planned_isolated[0]
2554            .effects
2555            .effects
2556            .iter()
2557            .find_map(|e| {
2558                if let crate::isolated_plan::IsolatedEffect::Blur { radius_px } = e {
2559                    Some(*radius_px)
2560                } else {
2561                    None
2562                }
2563            });
2564        assert_eq!(blur, Some(16.0));
2565        assert_eq!(budget.downgrades.blur_radius_clamped, 1);
2566    }
2567
2568    #[test]
2569    fn too_many_isolated_passes_rejected() {
2570        let mut layer_bufs: Vec<Vec<DrawCmd>> = Vec::new();
2571        for _ in 0..10 {
2572            layer_bufs.push(vec![DrawCmd::Rect(DrawRect {
2573                rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2574                color: Color::new(1.0, 0.0, 0.0, 1.0),
2575            })]);
2576            layer_bufs.push(vec![DrawCmd::Rect(DrawRect {
2577                rect: Rect::new(0.0, 0.0, 1.0, 1.0),
2578                color: Color::WHITE,
2579            })]);
2580        }
2581        let mut layers: Vec<LayerPass<'_>> = Vec::new();
2582        for (i, buf) in layer_bufs.iter().enumerate() {
2583            if i % 2 == 0 {
2584                let mut l = LayerPass::new_isolated(buf);
2585                l.effects.opacity = 0.5;
2586                layers.push(l);
2587            } else {
2588                layers.push(LayerPass::new(buf));
2589            }
2590        }
2591        let mut budget = IsolatedBudgetPolicy::with_defaults();
2592        budget.config.max_isolated_passes_per_frame = 4;
2593        let err = FrameRenderPlan::try_build_cpu(
2594            &layers,
2595            &empty_prepared(),
2596            [256.0, 256.0],
2597            Scale::new(1.0),
2598            true,
2599            &mut budget,
2600        )
2601        .unwrap_err();
2602        assert!(matches!(err, FramePlanError::TooManyIsolatedPasses { .. }));
2603    }
2604
2605    #[test]
2606    fn bytes_budget_skips_offscreen_pass() {
2607        let l1 = vec![DrawCmd::Rect(DrawRect {
2608            rect: Rect::new(0.0, 0.0, 200.0, 200.0),
2609            color: Color::new(1.0, 0.0, 0.0, 1.0),
2610        })];
2611        let mut layer = LayerPass::new_isolated(&l1);
2612        layer.effects.opacity = 0.5;
2613        let mut budget = IsolatedBudgetPolicy::with_defaults();
2614        budget.config.max_offscreen_bytes_per_frame = 1024;
2615        let plan = FrameRenderPlan::try_build_cpu(
2616            &[layer],
2617            &empty_prepared(),
2618            [256.0, 256.0],
2619            Scale::new(1.0),
2620            true,
2621            &mut budget,
2622        )
2623        .unwrap();
2624        assert!(plan.planned_isolated.is_empty());
2625        assert_eq!(budget.downgrades.bytes_budget_skipped, 1);
2626        assert!(!plan.layers.is_empty());
2627    }
2628
2629    #[test]
2630    fn isolated_composite_batch_between_normal_layers() {
2631        let bg = vec![DrawCmd::Rect(DrawRect {
2632            rect: Rect::new(0.0, 0.0, 128.0, 128.0),
2633            color: Color::new(1.0, 0.0, 0.0, 1.0),
2634        })];
2635        let iso = vec![DrawCmd::Rect(DrawRect {
2636            rect: Rect::new(32.0, 32.0, 40.0, 40.0),
2637            color: Color::new(0.0, 1.0, 0.0, 1.0),
2638        })];
2639        let top = vec![DrawCmd::Rect(DrawRect {
2640            rect: Rect::new(80.0, 80.0, 20.0, 20.0),
2641            color: Color::new(0.0, 0.0, 1.0, 1.0),
2642        })];
2643        let mut mid = LayerPass::new_isolated(&iso);
2644        mid.effects.opacity = 0.5;
2645        let plan = FrameRenderPlan::build_cpu(
2646            &[LayerPass::new(&bg), mid, LayerPass::new(&top)],
2647            &empty_prepared(),
2648            [128.0, 128.0],
2649            Scale::new(1.0),
2650            true,
2651        );
2652        assert_eq!(plan.layers.len(), 3);
2653        assert_eq!(plan.planned_isolated.len(), 1);
2654        assert!(plan
2655            .batches
2656            .iter()
2657            .any(|b| matches!(b, PlannedBatch::IsolatedComposite(_))));
2658        let prim_count = plan
2659            .batches
2660            .iter()
2661            .filter(|b| matches!(b, PlannedBatch::Primitives { .. }))
2662            .count();
2663        assert_eq!(prim_count, 2);
2664    }
2665
2666    #[test]
2667    fn local_remap_shifts_rect_to_origin() {
2668        let cmds = vec![DrawCmd::Rect(DrawRect {
2669            rect: Rect::new(40.0, 40.0, 20.0, 20.0),
2670            color: Color::new(1.0, 0.0, 0.0, 1.0),
2671        })];
2672        let mut layer = LayerPass::new_isolated(&cmds);
2673        layer.effects.opacity = 0.5;
2674        let prepared = empty_prepared();
2675        let plan =
2676            FrameRenderPlan::build_cpu(&[layer], &prepared, [128.0, 128.0], Scale::new(1.0), true);
2677        let iso = &plan.planned_isolated[0];
2678        let sub = FrameRenderPlan::build_isolated_subplan(
2679            &LayerPass::new_isolated(&cmds),
2680            &prepared,
2681            iso,
2682            Scale::new(1.0),
2683            true,
2684        );
2685        assert!(!sub.vertex_arena.is_empty());
2686        let min_x = sub
2687            .vertex_arena
2688            .iter()
2689            .map(|v| v.pos[0])
2690            .fold(f32::INFINITY, f32::min);
2691        assert!(
2692            min_x < 0.0,
2693            "local NDC x should start near left edge, got {min_x}"
2694        );
2695    }
2696
2697    #[test]
2698    fn stencil_attachment_required_only_when_any_layer_stencil() {
2699        let l1 = vec![DrawCmd::Rect(DrawRect {
2700            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2701            color: Color::new(1.0, 0.0, 0.0, 1.0),
2702        })];
2703        let no_stencil = FrameRenderPlan::build_cpu(
2704            &[LayerPass::new(&l1)],
2705            &empty_prepared(),
2706            [64.0, 64.0],
2707            Scale::new(1.0),
2708            true,
2709        );
2710        assert!(!no_stencil.needs_stencil_attachment);
2711
2712        let root_round = ClipShape::RoundRect {
2713            rect: Rect::new(0.0, 0.0, 64.0, 64.0),
2714            radius: 12.0,
2715        };
2716        let snap = ClipStackSnapshot::from_clip(Some(root_round), true);
2717        let with_stencil = FrameRenderPlan::build_cpu(
2718            &[LayerPass::with_clip_stack(&l1, snap)],
2719            &empty_prepared(),
2720            [64.0, 64.0],
2721            Scale::new(1.0),
2722            true,
2723        );
2724        assert!(with_stencil.needs_stencil_attachment);
2725    }
2726
2727    #[test]
2728    fn stencil_downgrades_to_shader_mask_when_unsupported() {
2729        let l1 = vec![DrawCmd::Rect(DrawRect {
2730            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2731            color: Color::new(1.0, 0.0, 0.0, 1.0),
2732        })];
2733        let root_round = ClipShape::RoundRect {
2734            rect: Rect::new(0.0, 0.0, 64.0, 64.0),
2735            radius: 12.0,
2736        };
2737        let snap = ClipStackSnapshot::from_clip(Some(root_round), true);
2738        let plan = FrameRenderPlan::build_cpu(
2739            &[LayerPass::with_clip_stack(&l1, snap)],
2740            &empty_prepared(),
2741            [64.0, 64.0],
2742            Scale::new(1.0),
2743            false, // stencil unsupported
2744        );
2745        assert_eq!(plan.layers.len(), 1);
2746        assert_eq!(plan.layers[0].clip_mode, ClipRenderMode::ShaderMask);
2747        assert!(plan.layers[0].stencil_ref.is_none());
2748        assert!(plan.layers[0].stencil_mask_range.is_none());
2749        assert!(!plan.needs_stencil_attachment);
2750    }
2751
2752    #[test]
2753    fn blend_multiply_schedules_dst_capture() {
2754        let bg = vec![DrawCmd::Rect(DrawRect {
2755            rect: Rect::new(0.0, 0.0, 128.0, 128.0),
2756            color: Color::new(1.0, 1.0, 0.0, 1.0),
2757        })];
2758        let iso = vec![DrawCmd::Rect(DrawRect {
2759            rect: Rect::new(32.0, 32.0, 64.0, 64.0),
2760            color: Color::new(1.0, 0.0, 0.0, 1.0),
2761        })];
2762        let mut layer = LayerPass::new_isolated(&iso);
2763        layer.effects.blend_mode = BlendMode::Multiply;
2764        let plan = FrameRenderPlan::build_cpu(
2765            &[LayerPass::new(&bg), layer],
2766            &empty_prepared(),
2767            [128.0, 128.0],
2768            Scale::new(1.0),
2769            true,
2770        );
2771        let iso_pass = &plan.planned_isolated[0];
2772        assert_eq!(iso_pass.composite_blend_mode, BlendMode::Multiply);
2773        assert!(iso_pass.needs_blend_dst_capture);
2774        let comp = plan
2775            .batches
2776            .iter()
2777            .find_map(|b| {
2778                if let PlannedBatch::IsolatedComposite(c) = b {
2779                    Some(c)
2780                } else {
2781                    None
2782                }
2783            })
2784            .expect("composite batch");
2785        assert_eq!(comp.blend_mode, BlendMode::Multiply);
2786        assert!(comp.needs_dst_capture);
2787        assert!(comp.dst_capture_rect_px.is_some());
2788    }
2789
2790    #[test]
2791    fn blend_budget_skip_downgrades_to_normal() {
2792        let iso = vec![DrawCmd::Rect(DrawRect {
2793            rect: Rect::new(0.0, 0.0, 128.0, 128.0),
2794            color: Color::new(1.0, 0.0, 0.0, 1.0),
2795        })];
2796        let mut layer = LayerPass::new_isolated(&iso);
2797        layer.effects.blend_mode = BlendMode::Screen;
2798        let mut budget = IsolatedBudgetPolicy::with_defaults();
2799        budget.config.max_blend_captures_per_frame = 0;
2800        let plan = FrameRenderPlan::try_build_cpu(
2801            &[layer],
2802            &empty_prepared(),
2803            [128.0, 128.0],
2804            Scale::new(1.0),
2805            true,
2806            &mut budget,
2807        )
2808        .expect("build");
2809        assert_eq!(budget.downgrades.blend_capture_budget_skipped, 1);
2810        let iso_pass = &plan.planned_isolated[0];
2811        assert_eq!(iso_pass.composite_blend_mode, BlendMode::Normal);
2812        assert!(!iso_pass.needs_blend_dst_capture);
2813    }
2814
2815    #[test]
2816    fn image_skipped_when_not_in_prepared() {
2817        let l1 = vec![DrawCmd::Image(DrawImage {
2818            rect: Rect::new(0.0, 0.0, 16.0, 16.0),
2819            icon: IconId::Plus,
2820            tint: Color::new(1.0, 1.0, 1.0, 1.0),
2821            rotation_rad: 0.0,
2822        })];
2823        // PreparedResources is empty → image must be skipped.
2824        let plan = FrameRenderPlan::build_cpu(
2825            &[LayerPass::new(&l1)],
2826            &empty_prepared(),
2827            [64.0, 64.0],
2828            Scale::new(1.0),
2829            true,
2830        );
2831        assert_eq!(plan.batches.len(), 0);
2832        assert_eq!(plan.tex_vertex_arena.len(), 0);
2833    }
2834}