Skip to main content

viewport_lib/renderer/
prepare.rs

1use super::types::{ClipShape, SceneEffects, ViewportEffects};
2use super::*;
3use wgpu::util::DeviceExt;
4
5impl ViewportRenderer {
6    /// Scene-global prepare stage: compute filters, lighting, shadow pass, batching, scivis.
7    ///
8    /// Call once per frame before any `prepare_viewport_internal` calls.
9    ///
10    /// Reads `scene_fx` for lighting, IBL, and compute filters.  Still reads
11    /// `frame.camera` for shadow cascade computation (Phase 1 coupling : see
12    /// multi-viewport-plan.md § shadow strategy; decoupled in Phase 2).
13    pub(super) fn prepare_scene_internal(
14        &mut self,
15        device: &wgpu::Device,
16        queue: &wgpu::Queue,
17        frame: &FrameData,
18        scene_fx: &SceneEffects<'_>,
19    ) {
20        // Phase G : GPU compute filtering.
21        // Dispatch before the render pass. Completely skipped when list is empty (zero overhead).
22        if !scene_fx.compute_filter_items.is_empty() {
23            self.compute_filter_results =
24                self.resources
25                    .run_compute_filters(device, queue, scene_fx.compute_filter_items);
26        } else {
27            self.compute_filter_results.clear();
28        }
29
30        // Ensure built-in colormaps and matcaps are uploaded on first frame.
31        self.resources.ensure_colormaps_initialized(device, queue);
32        self.resources.ensure_matcaps_initialized(device, queue);
33
34        let resources = &mut self.resources;
35        let lighting = scene_fx.lighting;
36
37        // Read scene items from the surface submission.
38        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
39            SurfaceSubmission::Flat(items) => items.as_ref(),
40        };
41
42        // Compute scene center / extent for shadow framing.
43        let (shadow_center, shadow_extent) = if let Some(extent) = lighting.shadow_extent_override {
44            (glam::Vec3::ZERO, extent)
45        } else {
46            (glam::Vec3::ZERO, 20.0)
47        };
48
49        /// Build a light-space view-projection matrix for shadow mapping.
50        fn compute_shadow_matrix(
51            kind: &LightKind,
52            shadow_center: glam::Vec3,
53            shadow_extent: f32,
54        ) -> glam::Mat4 {
55            match kind {
56                LightKind::Directional { direction } => {
57                    let dir = glam::Vec3::from(*direction).normalize();
58                    let light_up = if dir.z.abs() > 0.99 {
59                        glam::Vec3::Y
60                    } else {
61                        glam::Vec3::Z
62                    };
63                    let light_pos = shadow_center + dir * shadow_extent * 2.0;
64                    let light_view = glam::Mat4::look_at_rh(light_pos, shadow_center, light_up);
65                    let light_proj = glam::Mat4::orthographic_rh(
66                        -shadow_extent,
67                        shadow_extent,
68                        -shadow_extent,
69                        shadow_extent,
70                        0.01,
71                        shadow_extent * 5.0,
72                    );
73                    light_proj * light_view
74                }
75                LightKind::Point { position, range } => {
76                    let pos = glam::Vec3::from(*position);
77                    let to_center = (shadow_center - pos).normalize();
78                    let light_up = if to_center.z.abs() > 0.99 {
79                        glam::Vec3::Y
80                    } else {
81                        glam::Vec3::Z
82                    };
83                    let light_view = glam::Mat4::look_at_rh(pos, shadow_center, light_up);
84                    let light_proj =
85                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
86                    light_proj * light_view
87                }
88                LightKind::Spot {
89                    position,
90                    direction,
91                    range,
92                    ..
93                } => {
94                    let pos = glam::Vec3::from(*position);
95                    let dir = glam::Vec3::from(*direction).normalize();
96                    let look_target = pos + dir;
97                    let up = if dir.z.abs() > 0.99 {
98                        glam::Vec3::Y
99                    } else {
100                        glam::Vec3::Z
101                    };
102                    let light_view = glam::Mat4::look_at_rh(pos, look_target, up);
103                    let light_proj =
104                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
105                    light_proj * light_view
106                }
107            }
108        }
109
110        /// Convert a `LightSource` to `SingleLightUniform`, computing shadow matrix for lights[0].
111        fn build_single_light_uniform(
112            src: &LightSource,
113            shadow_center: glam::Vec3,
114            shadow_extent: f32,
115            compute_shadow: bool,
116        ) -> SingleLightUniform {
117            let shadow_mat = if compute_shadow {
118                compute_shadow_matrix(&src.kind, shadow_center, shadow_extent)
119            } else {
120                glam::Mat4::IDENTITY
121            };
122
123            match &src.kind {
124                LightKind::Directional { direction } => SingleLightUniform {
125                    light_view_proj: shadow_mat.to_cols_array_2d(),
126                    pos_or_dir: *direction,
127                    light_type: 0,
128                    color: src.color,
129                    intensity: src.intensity,
130                    range: 0.0,
131                    inner_angle: 0.0,
132                    outer_angle: 0.0,
133                    _pad_align: 0,
134                    spot_direction: [0.0, -1.0, 0.0],
135                    _pad: [0.0; 5],
136                },
137                LightKind::Point { position, range } => SingleLightUniform {
138                    light_view_proj: shadow_mat.to_cols_array_2d(),
139                    pos_or_dir: *position,
140                    light_type: 1,
141                    color: src.color,
142                    intensity: src.intensity,
143                    range: *range,
144                    inner_angle: 0.0,
145                    outer_angle: 0.0,
146                    _pad_align: 0,
147                    spot_direction: [0.0, -1.0, 0.0],
148                    _pad: [0.0; 5],
149                },
150                LightKind::Spot {
151                    position,
152                    direction,
153                    range,
154                    inner_angle,
155                    outer_angle,
156                } => SingleLightUniform {
157                    light_view_proj: shadow_mat.to_cols_array_2d(),
158                    pos_or_dir: *position,
159                    light_type: 2,
160                    color: src.color,
161                    intensity: src.intensity,
162                    range: *range,
163                    inner_angle: *inner_angle,
164                    outer_angle: *outer_angle,
165                    _pad_align: 0,
166                    spot_direction: *direction,
167                    _pad: [0.0; 5],
168                },
169            }
170        }
171
172        // Build the LightsUniform for all active lights (max 8).
173        let light_count = lighting.lights.len().min(8) as u32;
174        let mut lights_arr = [SingleLightUniform {
175            light_view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
176            pos_or_dir: [0.0; 3],
177            light_type: 0,
178            color: [1.0; 3],
179            intensity: 1.0,
180            range: 0.0,
181            inner_angle: 0.0,
182            outer_angle: 0.0,
183            _pad_align: 0,
184            spot_direction: [0.0, -1.0, 0.0],
185            _pad: [0.0; 5],
186        }; 8];
187
188        for (i, src) in lighting.lights.iter().take(8).enumerate() {
189            lights_arr[i] = build_single_light_uniform(src, shadow_center, shadow_extent, i == 0);
190        }
191
192        // -------------------------------------------------------------------
193        // Compute CSM cascade matrices for lights[0] (directional).
194        // Phase 1 note: uses frame.camera : see multi-viewport-plan.md § shadow strategy.
195        // -------------------------------------------------------------------
196        let cascade_count = lighting.shadow_cascade_count.clamp(1, 4) as usize;
197        let atlas_res = lighting.shadow_atlas_resolution.max(64);
198        let tile_size = atlas_res / 2;
199
200        let cascade_splits = compute_cascade_splits(
201            frame.camera.render_camera.near.max(0.01),
202            frame.camera.render_camera.far.max(1.0),
203            cascade_count as u32,
204            lighting.cascade_split_lambda,
205        );
206
207        let light_dir_for_csm = if light_count > 0 {
208            match &lighting.lights[0].kind {
209                LightKind::Directional { direction } => glam::Vec3::from(*direction).normalize(),
210                LightKind::Point { position, .. } => {
211                    (glam::Vec3::from(*position) - shadow_center).normalize()
212                }
213                LightKind::Spot {
214                    position,
215                    direction,
216                    ..
217                } => {
218                    let _ = position;
219                    glam::Vec3::from(*direction).normalize()
220                }
221            }
222        } else {
223            glam::Vec3::new(0.3, 1.0, 0.5).normalize()
224        };
225
226        let mut cascade_view_projs = [glam::Mat4::IDENTITY; 4];
227        // Distance-based splits for fragment shader cascade selection.
228        let mut cascade_split_distances = [0.0f32; 4];
229
230        // Determine if we should use CSM (directional light + valid camera data).
231        let use_csm = light_count > 0
232            && matches!(lighting.lights[0].kind, LightKind::Directional { .. })
233            && frame.camera.render_camera.view != glam::Mat4::IDENTITY;
234
235        if use_csm {
236            for i in 0..cascade_count {
237                let split_near = if i == 0 {
238                    frame.camera.render_camera.near.max(0.01)
239                } else {
240                    cascade_splits[i - 1]
241                };
242                let split_far = cascade_splits[i];
243                cascade_view_projs[i] = compute_cascade_matrix(
244                    light_dir_for_csm,
245                    frame.camera.render_camera.view,
246                    frame.camera.render_camera.fov,
247                    frame.camera.render_camera.aspect,
248                    split_near,
249                    split_far,
250                    tile_size as f32,
251                );
252                cascade_split_distances[i] = split_far;
253            }
254        } else {
255            // Fallback: single shadow map covering the whole scene (legacy behavior).
256            let primary_shadow_mat = if light_count > 0 {
257                compute_shadow_matrix(&lighting.lights[0].kind, shadow_center, shadow_extent)
258            } else {
259                glam::Mat4::IDENTITY
260            };
261            cascade_view_projs[0] = primary_shadow_mat;
262            cascade_split_distances[0] = frame.camera.render_camera.far;
263        }
264        let effective_cascade_count = if use_csm { cascade_count } else { 1 };
265
266        // Atlas tile layout (2x2 grid):
267        // [0] = top-left, [1] = top-right, [2] = bottom-left, [3] = bottom-right
268        let atlas_rects: [[f32; 4]; 8] = [
269            [0.0, 0.0, 0.5, 0.5], // cascade 0
270            [0.5, 0.0, 1.0, 0.5], // cascade 1
271            [0.0, 0.5, 0.5, 1.0], // cascade 2
272            [0.5, 0.5, 1.0, 1.0], // cascade 3
273            [0.0; 4],
274            [0.0; 4],
275            [0.0; 4],
276            [0.0; 4], // unused slots
277        ];
278
279        // Upload ShadowAtlasUniform (binding 5).
280        {
281            let mut vp_data = [[0.0f32; 4]; 16]; // 4 mat4s flattened
282            for c in 0..4 {
283                let cols = cascade_view_projs[c].to_cols_array_2d();
284                for row in 0..4 {
285                    vp_data[c * 4 + row] = cols[row];
286                }
287            }
288            let shadow_atlas_uniform = ShadowAtlasUniform {
289                cascade_view_proj: vp_data,
290                cascade_splits: cascade_split_distances,
291                cascade_count: effective_cascade_count as u32,
292                atlas_size: atlas_res as f32,
293                shadow_filter: match lighting.shadow_filter {
294                    ShadowFilter::Pcf => 0,
295                    ShadowFilter::Pcss => 1,
296                },
297                pcss_light_radius: lighting.pcss_light_radius,
298                atlas_rects,
299            };
300            queue.write_buffer(
301                &resources.shadow_info_buf,
302                0,
303                bytemuck::cast_slice(&[shadow_atlas_uniform]),
304            );
305            // Write to all per-viewport slot buffers so each viewport's bind group
306            // references correctly populated shadow info.
307            for slot in &self.viewport_slots {
308                queue.write_buffer(
309                    &slot.shadow_info_buf,
310                    0,
311                    bytemuck::cast_slice(&[shadow_atlas_uniform]),
312                );
313            }
314        }
315
316        // The primary shadow matrix is still stored in lights[0].light_view_proj for
317        // backward compat with the non-instanced shadow pass uniform.
318        let _primary_shadow_mat = cascade_view_projs[0];
319        // Cache for ground plane ShadowOnly mode.
320        self.last_cascade0_shadow_mat = cascade_view_projs[0];
321
322        // Upload lights uniform.
323        // IBL fields from environment map settings.
324        let (ibl_enabled, ibl_intensity, ibl_rotation, show_skybox) =
325            if let Some(env) = scene_fx.environment {
326                if resources.ibl_irradiance_view.is_some() {
327                    (
328                        1u32,
329                        env.intensity,
330                        env.rotation,
331                        if env.show_skybox { 1u32 } else { 0 },
332                    )
333                } else {
334                    (0, 0.0, 0.0, 0)
335                }
336            } else {
337                (0, 0.0, 0.0, 0)
338            };
339
340        let lights_uniform = LightsUniform {
341            count: light_count,
342            shadow_bias: lighting.shadow_bias,
343            shadows_enabled: if lighting.shadows_enabled { 1 } else { 0 },
344            _pad: 0,
345            sky_color: lighting.sky_color,
346            hemisphere_intensity: lighting.hemisphere_intensity,
347            ground_color: lighting.ground_color,
348            _pad2: 0.0,
349            lights: lights_arr,
350            ibl_enabled,
351            ibl_intensity,
352            ibl_rotation,
353            show_skybox,
354        };
355        queue.write_buffer(
356            &resources.light_uniform_buf,
357            0,
358            bytemuck::cast_slice(&[lights_uniform]),
359        );
360
361        // Upload all cascade matrices to the shadow uniform buffer before the shadow pass.
362        // wgpu batches write_buffer calls before the command buffer, so we must write ALL
363        // cascade slots up-front; the cascade loop then selects per-slot via dynamic offset.
364        const SHADOW_SLOT_STRIDE: u64 = 256;
365        for c in 0..4usize {
366            queue.write_buffer(
367                &resources.shadow_uniform_buf,
368                c as u64 * SHADOW_SLOT_STRIDE,
369                bytemuck::cast_slice(&cascade_view_projs[c].to_cols_array_2d()),
370            );
371        }
372
373        // -- Instancing preparation --
374        // Determine instancing mode BEFORE per-object uniforms so we can skip them.
375        let visible_count = scene_items.iter().filter(|i| i.visible).count();
376        let prev_use_instancing = self.use_instancing;
377        self.use_instancing = visible_count > INSTANCING_THRESHOLD;
378
379        // If instancing mode changed (e.g. objects added/removed crossing the threshold),
380        // clear batches so the generation check below forces a rebuild.
381        if self.use_instancing != prev_use_instancing {
382            self.instanced_batches.clear();
383            self.last_scene_generation = u64::MAX;
384            self.last_scene_items_count = usize::MAX;
385        }
386
387        // Per-object uniform writes : needed for the non-instanced path, wireframe mode,
388        // and for any items with active scalar attributes or two-sided materials
389        // (both bypass the instanced path).
390        let has_scalar_items = scene_items.iter().any(|i| i.active_attribute.is_some());
391        let has_two_sided_items = scene_items
392            .iter()
393            .any(|i| i.material.is_two_sided());
394        let has_matcap_items = scene_items.iter().any(|i| i.material.matcap_id.is_some());
395        let has_param_vis_items = scene_items.iter().any(|i| i.material.param_vis.is_some());
396        let has_wireframe_items = scene_items.iter().any(|i| i.render_as_wireframe);
397        if !self.use_instancing
398            || frame.viewport.wireframe_mode
399            || has_scalar_items
400            || has_two_sided_items
401            || has_matcap_items
402            || has_param_vis_items
403            || has_wireframe_items
404        {
405            for item in scene_items {
406                // When instancing is active, skip items that will be rendered
407                // via the instanced path. They don't need per-object uniform
408                // writes; writing them anyway causes O(n) write_buffer calls
409                // for the whole scene whenever any single item is two-sided.
410                if self.use_instancing
411                    && !frame.viewport.wireframe_mode
412                    && item.active_attribute.is_none()
413                    && !item.material.is_two_sided()
414                    && item.material.matcap_id.is_none()
415                    && item.material.param_vis.is_none()
416                    && !item.render_as_wireframe
417                    && item.warp_attribute.is_none()
418                {
419                    continue;
420                }
421
422                if resources
423                    .mesh_store
424                    .get(item.mesh_id)
425                    .is_none()
426                {
427                    tracing::warn!(
428                        mesh_index = item.mesh_id.index(),
429                        "scene item mesh_index invalid, skipping"
430                    );
431                    continue;
432                };
433                let m = &item.material;
434                // Compute scalar attribute range.
435                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
436                    let range = item
437                        .scalar_range
438                        .or_else(|| {
439                            resources
440                                .mesh_store
441                                .get(item.mesh_id)
442                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
443                        })
444                        .unwrap_or((0.0, 1.0));
445                    (1u32, range.0, range.1)
446                } else {
447                    (0u32, 0.0, 1.0)
448                };
449                let obj_uniform = ObjectUniform {
450                    model: item.model,
451                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
452                    selected: if item.selected { 1 } else { 0 },
453                    wireframe: if frame.viewport.wireframe_mode || item.render_as_wireframe { 1 } else { 0 },
454                    ambient: m.ambient,
455                    diffuse: m.diffuse,
456                    specular: m.specular,
457                    shininess: m.shininess,
458                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
459                    use_pbr: if m.use_pbr { 1 } else { 0 },
460                    metallic: m.metallic,
461                    roughness: m.roughness,
462                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
463                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
464                    has_attribute: has_attr,
465                    scalar_min: s_min,
466                    scalar_max: s_max,
467                    _pad_scalar: 0,
468                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
469                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
470                    use_matcap: if m.matcap_id.is_some() { 1 } else { 0 },
471                    matcap_blendable: m.matcap_id.map_or(0, |id| if id.blendable { 1 } else { 0 }),
472                    unlit: if m.unlit { 1 } else { 0 },
473                    use_face_color: u32::from(item.active_attribute.as_ref().map_or(false, |a| {
474                        a.kind == crate::resources::AttributeKind::FaceColor
475                    })),
476                    uv_vis_mode: m.param_vis.map_or(0, |pv| pv.mode as u32),
477                    uv_vis_scale: m.param_vis.map_or(8.0, |pv| pv.scale),
478                    backface_policy: match m.backface_policy {
479                        crate::scene::material::BackfacePolicy::Cull => 0,
480                        crate::scene::material::BackfacePolicy::Identical => 1,
481                        crate::scene::material::BackfacePolicy::DifferentColor(_) => 2,
482                        crate::scene::material::BackfacePolicy::Tint(_) => 3,
483                        crate::scene::material::BackfacePolicy::Pattern(cfg) => {
484                            4 + cfg.pattern as u32
485                        }
486                    },
487                    backface_color: match m.backface_policy {
488                        crate::scene::material::BackfacePolicy::DifferentColor(c) => {
489                            [c[0], c[1], c[2], 1.0]
490                        }
491                        crate::scene::material::BackfacePolicy::Tint(factor) => {
492                            [factor, 0.0, 0.0, 1.0]
493                        }
494                        crate::scene::material::BackfacePolicy::Pattern(cfg) => {
495                            let world_extent = resources
496                                .mesh_store
497                                .get(item.mesh_id)
498                                .map(|mesh| {
499                                    mesh.aabb
500                                        .transformed(&glam::Mat4::from_cols_array_2d(&item.model))
501                                        .longest_side()
502                                })
503                                .unwrap_or(1.0)
504                                .max(1e-6);
505                            let world_scale = cfg.scale / world_extent;
506                            [cfg.color[0], cfg.color[1], cfg.color[2], world_scale]
507                        }
508                        _ => [0.0; 4],
509                    },
510                    has_warp: if item.warp_attribute.is_some() { 1 } else { 0 },
511                    warp_scale: item.warp_scale,
512                    _pad_warp: [0; 2],
513                };
514
515                let normal_obj_uniform = ObjectUniform {
516                    model: item.model,
517                    color: [1.0, 1.0, 1.0, 1.0],
518                    selected: 0,
519                    wireframe: 0,
520                    ambient: 0.15,
521                    diffuse: 0.75,
522                    specular: 0.4,
523                    shininess: 32.0,
524                    has_texture: 0,
525                    use_pbr: 0,
526                    metallic: 0.0,
527                    roughness: 0.5,
528                    has_normal_map: 0,
529                    has_ao_map: 0,
530                    has_attribute: 0,
531                    scalar_min: 0.0,
532                    scalar_max: 1.0,
533                    _pad_scalar: 0,
534                    nan_color: [0.0; 4],
535                    use_nan_color: 0,
536                    use_matcap: 0,
537                    matcap_blendable: 0,
538                    unlit: 0,
539                    use_face_color: 0,
540                    uv_vis_mode: 0,
541                    uv_vis_scale: 8.0,
542                    backface_policy: 0,
543                    backface_color: [0.0; 4],
544                    has_warp: 0,
545                    warp_scale: 1.0,
546                    _pad_warp: [0; 2],
547                };
548
549                // Write uniform data : use get() to read buffer references, then drop.
550                {
551                    let mesh = resources
552                        .mesh_store
553                        .get(item.mesh_id)
554                        .unwrap();
555                    queue.write_buffer(
556                        &mesh.object_uniform_buf,
557                        0,
558                        bytemuck::cast_slice(&[obj_uniform]),
559                    );
560                    queue.write_buffer(
561                        &mesh.normal_uniform_buf,
562                        0,
563                        bytemuck::cast_slice(&[normal_obj_uniform]),
564                    );
565                } // mesh borrow dropped here
566
567                // Rebuild the object bind group if material/attribute/LUT/matcap/warp changed.
568                resources.update_mesh_texture_bind_group(
569                    device,
570                    item.mesh_id,
571                    item.material.texture_id,
572                    item.material.normal_map_id,
573                    item.material.ao_map_id,
574                    item.colormap_id,
575                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
576                    item.material.matcap_id,
577                    item.warp_attribute.as_deref(),
578                );
579            }
580        }
581
582        if self.use_instancing {
583            resources.ensure_instanced_pipelines(device);
584
585            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
586            // Phase 2: wireframe_mode removed from cache key : wireframe rendering
587            // uses the per-object wireframe_pipeline, not the instanced path, so
588            // instance data is now viewport-agnostic.
589            //
590            // Items with active_attribute, two-sided policy, matcap, or param_vis are
591            // excluded from the instanced batch filter. These flags are set on render
592            // items AFTER collect_render_items() (per-frame mutations), so they do NOT
593            // bump the scene generation. Use last_instancable_count as a cache key
594            // instead of a blanket has_per_frame_mutations flag; this allows scenes
595            // that mix instanced and non-instanced items (e.g. one two-sided mesh +
596            // many static boxes) to still hit the instanced batch cache on frames
597            // where the filtered set is unchanged.
598            let instancable_count = scene_items.iter().filter(|item| {
599                item.visible
600                    && item.active_attribute.is_none()
601                    && !item.material.is_two_sided()
602                    && item.material.matcap_id.is_none()
603                    && item.material.param_vis.is_none()
604                    && resources.mesh_store.get(item.mesh_id).is_some()
605            }).count();
606            let cache_valid = instancable_count == self.last_instancable_count
607                && frame.scene.generation == self.last_scene_generation
608                && frame.interaction.selection_generation == self.last_selection_generation
609                && scene_items.len() == self.last_scene_items_count;
610
611            if !cache_valid {
612                // Cache miss : rebuild batches and upload instance data.
613                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
614                    .iter()
615                    .filter(|item| {
616                        item.visible
617                            && item.active_attribute.is_none()
618                            && !item.material.is_two_sided()
619                            && item.material.matcap_id.is_none()
620                            && item.material.param_vis.is_none()
621                            && resources
622                                .mesh_store
623                                .get(item.mesh_id)
624                                .is_some()
625                    })
626                    .collect();
627
628                sorted_items.sort_unstable_by_key(|item| {
629                    (
630                        item.mesh_id.index(),
631                        item.material.texture_id,
632                        item.material.normal_map_id,
633                        item.material.ao_map_id,
634                    )
635                });
636
637                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
638                let mut all_aabbs: Vec<InstanceAabb> = Vec::with_capacity(sorted_items.len());
639                let mut batch_metas: Vec<BatchMeta> = Vec::new();
640                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
641
642                if !sorted_items.is_empty() {
643                    let mut batch_start = 0usize;
644                    for i in 1..=sorted_items.len() {
645                        let at_end = i == sorted_items.len();
646                        let key_changed = !at_end && {
647                            let a = sorted_items[batch_start];
648                            let b = sorted_items[i];
649                            a.mesh_id != b.mesh_id
650                                || a.material.texture_id != b.material.texture_id
651                                || a.material.normal_map_id != b.material.normal_map_id
652                                || a.material.ao_map_id != b.material.ao_map_id
653                        };
654
655                        if at_end || key_changed {
656                            let batch_items = &sorted_items[batch_start..i];
657                            let rep = batch_items[0];
658                            let instance_offset = all_instances.len() as u32;
659                            let is_transparent = rep.material.opacity < 1.0;
660
661                            for item in batch_items {
662                                let m = &item.material;
663                                all_instances.push(InstanceData {
664                                    model: item.model,
665                                    color: [
666                                        m.base_color[0],
667                                        m.base_color[1],
668                                        m.base_color[2],
669                                        m.opacity,
670                                    ],
671                                    selected: if item.selected { 1 } else { 0 },
672                                    wireframe: 0, // Phase 2: always 0 : wireframe uses per-object pipeline
673                                    ambient: m.ambient,
674                                    diffuse: m.diffuse,
675                                    specular: m.specular,
676                                    shininess: m.shininess,
677                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
678                                    use_pbr: if m.use_pbr { 1 } else { 0 },
679                                    metallic: m.metallic,
680                                    roughness: m.roughness,
681                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
682                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
683                                    unlit: if m.unlit { 1 } else { 0 },
684                                    _pad_inst: [0; 3],
685                                });
686                            }
687
688                            // Build per-instance AABBs alongside instance data.
689                            // All items in a batch share the same mesh_id (batch key), so
690                            // mesh.index_count is the same for every item — look it up once.
691                            let batch_idx = instanced_batches.len() as u32;
692                            let mesh_index_count = resources
693                                .mesh_store
694                                .get(rep.mesh_id)
695                                .map(|m| m.index_count)
696                                .unwrap_or(0);
697                            for item in batch_items {
698                                if let Some(mesh) = resources.mesh_store.get(item.mesh_id) {
699                                    let model =
700                                        glam::Mat4::from_cols_array_2d(&item.model);
701                                    let world_aabb = mesh.aabb.transformed(&model);
702                                    all_aabbs.push(InstanceAabb {
703                                        min: world_aabb.min.into(),
704                                        batch_index: batch_idx,
705                                        max: world_aabb.max.into(),
706                                        _pad: 0,
707                                    });
708                                }
709                            }
710
711                            // vis_offset is the prefix sum of instance counts; since
712                            // instances are laid out contiguously per batch, it equals
713                            // instance_offset.
714                            batch_metas.push(BatchMeta {
715                                index_count: mesh_index_count,
716                                first_index: 0,
717                                instance_offset,
718                                instance_count: batch_items.len() as u32,
719                                vis_offset: instance_offset,
720                                is_transparent: if is_transparent { 1 } else { 0 },
721                                _pad: [0, 0],
722                            });
723
724                            instanced_batches.push(InstancedBatch {
725                                mesh_id: rep.mesh_id,
726                                texture_id: rep.material.texture_id,
727                                normal_map_id: rep.material.normal_map_id,
728                                ao_map_id: rep.material.ao_map_id,
729                                instance_offset,
730                                instance_count: batch_items.len() as u32,
731                                is_transparent,
732                            });
733
734                            batch_start = i;
735                        }
736                    }
737                }
738
739                self.cached_instance_data = all_instances;
740                self.cached_instanced_batches = instanced_batches;
741
742                resources.upload_instance_data(device, queue, &self.cached_instance_data);
743                resources.upload_aabb_and_batch_meta(device, queue, &all_aabbs, &batch_metas);
744
745                self.instanced_batches = self.cached_instanced_batches.clone();
746
747                self.last_scene_generation = frame.scene.generation;
748                self.last_selection_generation = frame.interaction.selection_generation;
749                self.last_scene_items_count = scene_items.len();
750                self.last_instancable_count = sorted_items.len();
751
752                for batch in &self.instanced_batches {
753                    resources.get_instance_bind_group(
754                        device,
755                        batch.texture_id,
756                        batch.normal_map_id,
757                        batch.ao_map_id,
758                    );
759                }
760            } else {
761                for batch in &self.instanced_batches {
762                    resources.get_instance_bind_group(
763                        device,
764                        batch.texture_id,
765                        batch.normal_map_id,
766                        batch.ao_map_id,
767                    );
768                }
769            }
770
771            // ------------------------------------------------------------------
772            // GPU cull dispatch (Phase 3)
773            //
774            // Run `cull_instances` + `write_indirect_args` whenever GPU culling
775            // is active and all required buffers are allocated.
776            // ------------------------------------------------------------------
777            if self.gpu_culling_enabled
778                && !self.instanced_batches.is_empty()
779                && !self.cached_instance_data.is_empty()
780            {
781                let instance_count = self.cached_instance_data.len() as u32;
782                let batch_count = self.instanced_batches.len() as u32;
783
784                // Do all mutable borrows before taking immutable borrows from resources.
785                if self.cull_resources.is_none() {
786                    self.cull_resources =
787                        Some(crate::renderer::indirect::CullResources::new(device));
788                }
789                resources.ensure_cull_instance_pipelines(device);
790                for batch in &self.instanced_batches.clone() {
791                    resources.get_instance_cull_bind_group(
792                        device,
793                        batch.texture_id,
794                        batch.normal_map_id,
795                        batch.ao_map_id,
796                    );
797                }
798
799                // Now take immutable borrows to the GPU buffers for dispatch.
800                if let (
801                    Some(aabb_buf),
802                    Some(meta_buf),
803                    Some(counter_buf),
804                    Some(vis_buf),
805                    Some(indirect_buf),
806                ) = (
807                    resources.instance_aabb_buf.as_ref(),
808                    resources.batch_meta_buf.as_ref(),
809                    resources.batch_counter_buf.as_ref(),
810                    resources.visibility_index_buf.as_ref(),
811                    resources.indirect_args_buf.as_ref(),
812                ) {
813                    // Build the FrustumUniform from the current camera view-projection.
814                    let vp_mat = frame.camera.render_camera.view_proj();
815                    let cpu_frustum =
816                        crate::camera::frustum::Frustum::from_view_proj(&vp_mat);
817                    let frustum_uniform = crate::resources::FrustumUniform {
818                        planes: std::array::from_fn(|i| crate::resources::FrustumPlane {
819                            normal: cpu_frustum.planes[i].normal.into(),
820                            distance: cpu_frustum.planes[i].d,
821                        }),
822                        instance_count,
823                        batch_count,
824                        _pad: [0; 2],
825                    };
826
827                    let cull = self.cull_resources.as_ref().unwrap();
828                    let mut encoder =
829                        device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
830                            label: Some("cull_encoder"),
831                        });
832                    cull.dispatch(
833                        &mut encoder,
834                        device,
835                        queue,
836                        &frustum_uniform,
837                        aabb_buf,
838                        meta_buf,
839                        counter_buf,
840                        vis_buf,
841                        indirect_buf,
842                        instance_count,
843                        batch_count,
844                    );
845
846                    // Copy indirect_args_buf to the CPU-readable staging buffer so the
847                    // visible instance count can be read back next frame (one-frame lag).
848                    let indirect_bytes = batch_count as u64 * 20;
849                    if self
850                        .indirect_readback_buf
851                        .as_ref()
852                        .map_or(0, |b| b.size())
853                        < indirect_bytes
854                    {
855                        self.indirect_readback_buf =
856                            Some(device.create_buffer(&wgpu::BufferDescriptor {
857                                label: Some("indirect_readback_buf"),
858                                size: indirect_bytes,
859                                usage: wgpu::BufferUsages::COPY_DST
860                                    | wgpu::BufferUsages::MAP_READ,
861                                mapped_at_creation: false,
862                            }));
863                    }
864                    if let Some(ref rb_buf) = self.indirect_readback_buf {
865                        encoder.copy_buffer_to_buffer(
866                            indirect_buf,
867                            0,
868                            rb_buf,
869                            0,
870                            indirect_bytes,
871                        );
872                    }
873                    queue.submit(std::iter::once(encoder.finish()));
874                    self.indirect_readback_batch_count = batch_count;
875                    self.indirect_readback_pending = true;
876                }
877            }
878        }
879
880        // ------------------------------------------------------------------
881        // SciVis Phase B : point cloud and glyph GPU data upload.
882        // ------------------------------------------------------------------
883        self.point_cloud_gpu_data.clear();
884        if !frame.scene.point_clouds.is_empty() {
885            resources.ensure_point_cloud_pipeline(device);
886            for item in &frame.scene.point_clouds {
887                if item.positions.is_empty() {
888                    continue;
889                }
890                let gpu_data = resources.upload_point_cloud(device, queue, item);
891                self.point_cloud_gpu_data.push(gpu_data);
892            }
893        }
894
895        self.glyph_gpu_data.clear();
896        if !frame.scene.glyphs.is_empty() {
897            resources.ensure_glyph_pipeline(device);
898            for item in &frame.scene.glyphs {
899                if item.positions.is_empty() || item.vectors.is_empty() {
900                    continue;
901                }
902                let gpu_data = resources.upload_glyph_set(device, queue, item);
903                self.glyph_gpu_data.push(gpu_data);
904            }
905        }
906
907        // ------------------------------------------------------------------
908        // SciVis Phase 5 : tensor glyph GPU data upload.
909        // ------------------------------------------------------------------
910        self.tensor_glyph_gpu_data.clear();
911        if !frame.scene.tensor_glyphs.is_empty() {
912            resources.ensure_tensor_glyph_pipeline(device);
913            for item in &frame.scene.tensor_glyphs {
914                if item.positions.is_empty() {
915                    continue;
916                }
917                let gd = resources.upload_tensor_glyph_set(device, queue, item);
918                self.tensor_glyph_gpu_data.push(gd);
919            }
920        }
921
922        // ------------------------------------------------------------------
923        // SciVis Phase M8 : polyline GPU data upload.
924        // ------------------------------------------------------------------
925        self.polyline_gpu_data.clear();
926        let vp_size = frame.camera.viewport_size;
927        if !frame.scene.polylines.is_empty() {
928            resources.ensure_polyline_pipeline(device);
929            for item in &frame.scene.polylines {
930                if item.positions.is_empty() {
931                    continue;
932                }
933                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
934                self.polyline_gpu_data.push(gpu_data);
935
936                // Phase 11: auto-generate GlyphItems for node/edge vector quantities.
937                if !item.node_vectors.is_empty() {
938                    resources.ensure_glyph_pipeline(device);
939                    let g = crate::quantities::polyline_node_vectors_to_glyphs(item);
940                    if !g.positions.is_empty() {
941                        let gd = resources.upload_glyph_set(device, queue, &g);
942                        self.glyph_gpu_data.push(gd);
943                    }
944                }
945                if !item.edge_vectors.is_empty() {
946                    resources.ensure_glyph_pipeline(device);
947                    let g = crate::quantities::polyline_edge_vectors_to_glyphs(item);
948                    if !g.positions.is_empty() {
949                        let gd = resources.upload_glyph_set(device, queue, &g);
950                        self.glyph_gpu_data.push(gd);
951                    }
952                }
953            }
954        }
955
956        // ------------------------------------------------------------------
957        // SciVis Phase L : isoline extraction and upload via polyline pipeline.
958        // ------------------------------------------------------------------
959        if !frame.scene.isolines.is_empty() {
960            resources.ensure_polyline_pipeline(device);
961            for item in &frame.scene.isolines {
962                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
963                    continue;
964                }
965                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
966                if positions.is_empty() {
967                    continue;
968                }
969                let polyline = PolylineItem {
970                    positions,
971                    scalars: Vec::new(),
972                    strip_lengths,
973                    scalar_range: None,
974                    colormap_id: None,
975                    default_color: item.color,
976                    line_width: item.line_width,
977                    id: 0,
978                    ..Default::default()
979                };
980                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
981                self.polyline_gpu_data.push(gpu_data);
982            }
983        }
984
985        // ------------------------------------------------------------------
986        // Phase 10A : camera frustum wireframes (converted to polylines).
987        // ------------------------------------------------------------------
988        if !frame.scene.camera_frustums.is_empty() {
989            resources.ensure_polyline_pipeline(device);
990            for item in &frame.scene.camera_frustums {
991                let polyline = item.to_polyline();
992                if !polyline.positions.is_empty() {
993                    let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
994                    self.polyline_gpu_data.push(gpu_data);
995                }
996            }
997        }
998
999        // ------------------------------------------------------------------
1000        // Phase 16 : GPU implicit surface items.
1001        // ------------------------------------------------------------------
1002        self.implicit_gpu_data.clear();
1003        if !frame.scene.gpu_implicit.is_empty() {
1004            resources.ensure_implicit_pipeline(device);
1005            for item in &frame.scene.gpu_implicit {
1006                if item.primitives.is_empty() {
1007                    continue;
1008                }
1009                let gpu = resources.upload_implicit_item(device, item);
1010                self.implicit_gpu_data.push(gpu);
1011            }
1012        }
1013
1014        // ------------------------------------------------------------------
1015        // Phase 17 : GPU marching cubes compute dispatch.
1016        // ------------------------------------------------------------------
1017        self.mc_gpu_data.clear();
1018        if !frame.scene.gpu_mc_jobs.is_empty() {
1019            resources.ensure_mc_pipelines(device);
1020            self.mc_gpu_data =
1021                resources.run_mc_jobs(device, queue, &frame.scene.gpu_mc_jobs);
1022        }
1023
1024        // ------------------------------------------------------------------
1025        // Phase 10B : screen-space image overlays.
1026        // ------------------------------------------------------------------
1027        self.screen_image_gpu_data.clear();
1028        if !frame.scene.screen_images.is_empty() {
1029            resources.ensure_screen_image_pipeline(device);
1030            // Phase 12: ensure dc pipeline if any item carries depth data.
1031            if frame.scene.screen_images.iter().any(|i| i.depth.is_some()) {
1032                resources.ensure_screen_image_dc_pipeline(device);
1033            }
1034            let vp_w = vp_size[0];
1035            let vp_h = vp_size[1];
1036            for item in &frame.scene.screen_images {
1037                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
1038                    continue;
1039                }
1040                let gpu = resources.upload_screen_image(device, queue, item, vp_w, vp_h);
1041                self.screen_image_gpu_data.push(gpu);
1042            }
1043        }
1044
1045        // ------------------------------------------------------------------
1046        // Phase 7 : overlay image overlays (OverlayFrame).
1047        // ------------------------------------------------------------------
1048        self.overlay_image_gpu_data.clear();
1049        if !frame.overlays.images.is_empty() {
1050            resources.ensure_screen_image_pipeline(device);
1051            let vp_w = vp_size[0];
1052            let vp_h = vp_size[1];
1053            for item in &frame.overlays.images {
1054                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
1055                    continue;
1056                }
1057                let gpu = resources.upload_overlay_image(device, queue, item, vp_w, vp_h);
1058                self.overlay_image_gpu_data.push(gpu);
1059            }
1060        }
1061
1062        // ------------------------------------------------------------------
1063        // SciVis Phase M : streamtube GPU data upload.
1064        // ------------------------------------------------------------------
1065        self.streamtube_gpu_data.clear();
1066        if !frame.scene.streamtube_items.is_empty() {
1067            resources.ensure_streamtube_pipeline(device);
1068            for item in &frame.scene.streamtube_items {
1069                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1070                    continue;
1071                }
1072                let gpu_data = resources.upload_streamtube(device, queue, item);
1073                if gpu_data.index_count > 0 {
1074                    self.streamtube_gpu_data.push(gpu_data);
1075                }
1076            }
1077        }
1078
1079        // ------------------------------------------------------------------
1080        // Phase 3.3 : General Tube GPU data upload.
1081        // ------------------------------------------------------------------
1082        self.tube_gpu_data.clear();
1083        if !frame.scene.tube_items.is_empty() {
1084            resources.ensure_streamtube_pipeline(device);
1085            for item in &frame.scene.tube_items {
1086                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1087                    continue;
1088                }
1089                let gpu_data = resources.upload_tube(device, queue, item);
1090                if gpu_data.index_count > 0 {
1091                    self.tube_gpu_data.push(gpu_data);
1092                }
1093            }
1094        }
1095
1096        // ------------------------------------------------------------------
1097        // Phase 8.1 : Ribbon GPU data upload.
1098        // ------------------------------------------------------------------
1099        self.ribbon_gpu_data.clear();
1100        if !frame.scene.ribbon_items.is_empty() {
1101            resources.ensure_streamtube_pipeline(device);
1102            for item in &frame.scene.ribbon_items {
1103                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1104                    continue;
1105                }
1106                let gpu_data = resources.upload_ribbon(device, queue, item);
1107                if gpu_data.index_count > 0 {
1108                    self.ribbon_gpu_data.push(gpu_data);
1109                }
1110            }
1111        }
1112
1113        // ------------------------------------------------------------------
1114        // Phase 3.2 : Image Slice GPU data upload.
1115        // ------------------------------------------------------------------
1116        self.image_slice_gpu_data.clear();
1117        if !frame.scene.image_slices.is_empty() {
1118            resources.ensure_image_slice_pipeline(device);
1119            for item in &frame.scene.image_slices {
1120                if let Some(gpu_data) = resources.upload_image_slice(device, queue, item) {
1121                    self.image_slice_gpu_data.push(gpu_data);
1122                }
1123            }
1124        }
1125
1126        // ------------------------------------------------------------------
1127        // Phase 4: Surface LIC GPU data upload.
1128        // ------------------------------------------------------------------
1129        self.lic_gpu_data.clear();
1130        if !frame.scene.lic_items.is_empty() {
1131            // The LIC surface pipeline is created inside ensure_hdr_shared (already called before
1132            // prepare_scene_internal runs), so no separate ensure call is needed here.
1133            for item in &frame.scene.lic_items {
1134                if item.vector_attribute.is_empty() {
1135                    continue;
1136                }
1137                if let Some(mesh) = resources.mesh_store.get(item.mesh_id) {
1138                    // Verify the vector attribute buffer exists before committing to this item.
1139                    if mesh.vector_attribute_buffers.contains_key(&item.vector_attribute) {
1140                        if let Some(bgl) = &resources.lic_surface_bgl {
1141                            use crate::resources::LicObjectUniform;
1142                            let model = item.model;
1143                            let obj_data = LicObjectUniform { model };
1144                            let obj_buf = device.create_buffer(&wgpu::BufferDescriptor {
1145                                label: Some("lic_object_uniform"),
1146                                size: std::mem::size_of::<LicObjectUniform>() as u64,
1147                                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1148                                mapped_at_creation: false,
1149                            });
1150                            queue.write_buffer(&obj_buf, 0, bytemuck::cast_slice(&[obj_data]));
1151                            // Bind group (group 1): object uniform only.
1152                            // Flow vectors are bound as vertex buffer 1 in the render pass.
1153                            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1154                                label: Some("lic_surface_item_bg"),
1155                                layout: bgl,
1156                                entries: &[
1157                                    wgpu::BindGroupEntry {
1158                                        binding: 0,
1159                                        resource: obj_buf.as_entire_binding(),
1160                                    },
1161                                ],
1162                            });
1163                            self.lic_gpu_data.push(crate::resources::LicSurfaceGpuData {
1164                                bind_group: bg,
1165                                _object_uniform_buf: obj_buf,
1166                                mesh_id: item.mesh_id,
1167                                vector_attribute: item.vector_attribute.clone(),
1168                            });
1169                        }
1170                    }
1171                }
1172            }
1173            // Write LicAdvectUniform to the per-viewport buffer.
1174            if let Some(hdr) = self.viewport_slots[frame.camera.viewport_index].hdr.as_ref() {
1175                if let Some(first) = frame.scene.lic_items.first() {
1176                    let [vw, vh] = hdr.size;
1177                    let u = crate::resources::LicAdvectUniform {
1178                        steps: first.config.steps,
1179                        step_size: first.config.step_size,
1180                        vp_width: vw as f32,
1181                        vp_height: vh as f32,
1182                    };
1183                    queue.write_buffer(&hdr.lic_uniform_buf, 0, bytemuck::cast_slice(&[u]));
1184                }
1185            }
1186        }
1187
1188        // ------------------------------------------------------------------
1189        // SciVis Phase D : volume GPU data upload.
1190        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
1191        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
1192        // ------------------------------------------------------------------
1193        self.volume_gpu_data.clear();
1194        if !frame.scene.volumes.is_empty() {
1195            resources.ensure_volume_pipeline(device);
1196            let clip_objects_for_vol = &frame.effects.clip_objects;
1197            // Phase 5: under budget pressure with allow_volume_quality_reduction, double the
1198            // step size (half the sample count) to reduce GPU raymarch cost.
1199            let vol_step_multiplier = if self.degradation_volume_quality_reduced {
1200                2.0_f32
1201            } else {
1202                1.0_f32
1203            };
1204            for item in &frame.scene.volumes {
1205                let gpu = resources.upload_volume_frame(
1206                    device,
1207                    queue,
1208                    item,
1209                    clip_objects_for_vol,
1210                    vol_step_multiplier,
1211                );
1212                self.volume_gpu_data.push(gpu);
1213            }
1214        }
1215
1216        // -- Frame stats --
1217        {
1218            let total = scene_items.len() as u32;
1219            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
1220            let mut draw_calls = 0u32;
1221            let mut triangles = 0u64;
1222            let instanced_batch_count = if self.use_instancing {
1223                self.instanced_batches.len() as u32
1224            } else {
1225                0
1226            };
1227
1228            if self.use_instancing {
1229                for batch in &self.instanced_batches {
1230                    if let Some(mesh) = resources
1231                        .mesh_store
1232                        .get(batch.mesh_id)
1233                    {
1234                        draw_calls += 1;
1235                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
1236                    }
1237                }
1238            } else {
1239                for item in scene_items {
1240                    if !item.visible {
1241                        continue;
1242                    }
1243                    if let Some(mesh) = resources
1244                        .mesh_store
1245                        .get(item.mesh_id)
1246                    {
1247                        draw_calls += 1;
1248                        triangles += (mesh.index_count / 3) as u64;
1249                    }
1250                }
1251            }
1252
1253            self.last_stats = crate::renderer::stats::FrameStats {
1254                total_objects: total,
1255                visible_objects: visible,
1256                culled_objects: total.saturating_sub(visible),
1257                draw_calls,
1258                instanced_batches: instanced_batch_count,
1259                triangles_submitted: triangles,
1260                shadow_draw_calls: 0, // Updated below in shadow pass.
1261                gpu_culling_active: self.gpu_culling_enabled,
1262                // Clear stale readback if GPU culling is off this frame.
1263                gpu_visible_instances: if self.gpu_culling_enabled {
1264                    self.last_stats.gpu_visible_instances
1265                } else {
1266                    None
1267                },
1268                ..self.last_stats
1269            };
1270        }
1271
1272        // ------------------------------------------------------------------
1273        // Shadow depth pass : CSM: render each cascade into its atlas tile.
1274        // Phase 5: skip the pass entirely when over budget and shadow reduction is allowed.
1275        // ------------------------------------------------------------------
1276        let skip_shadows = self.degradation_shadows_skipped;
1277
1278        // When skipping the shadow pass (budget pressure or empty scene), clear the
1279        // atlas to max depth so that stale values from a previous frame or a previous
1280        // showcase don't produce phantom shadows.
1281        if lighting.shadows_enabled && (skip_shadows || scene_items.is_empty()) {
1282            let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1283                label: Some("shadow_clear_encoder"),
1284            });
1285            let _ = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
1286                label: Some("shadow_clear_pass"),
1287                color_attachments: &[],
1288                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1289                    view: &resources.shadow_map_view,
1290                    depth_ops: Some(wgpu::Operations {
1291                        load: wgpu::LoadOp::Clear(1.0),
1292                        store: wgpu::StoreOp::Store,
1293                    }),
1294                    stencil_ops: None,
1295                }),
1296                timestamp_writes: None,
1297                occlusion_query_set: None,
1298            });
1299            queue.submit(std::iter::once(enc.finish()));
1300        }
1301
1302        if lighting.shadows_enabled && !scene_items.is_empty() && !skip_shadows {
1303            // ------------------------------------------------------------------
1304            // Shadow GPU cull dispatch (Phase 4)
1305            //
1306            // For each active cascade, dispatch `cull_instances` + `write_indirect_args`
1307            // with the cascade frustum. Results land in `shadow_vis_bufs[c]` and
1308            // `shadow_indirect_bufs[c]`, consumed by the shadow render pass below.
1309            // All cascade dispatches share the same `batch_counter_buf`; each
1310            // `write_indirect_args` dispatch resets the counters for the next cascade.
1311            // ------------------------------------------------------------------
1312            if self.gpu_culling_enabled
1313                && self.use_instancing
1314                && !self.instanced_batches.is_empty()
1315                && !self.cached_instance_data.is_empty()
1316            {
1317                // Mutable operations first.
1318                if self.cull_resources.is_none() {
1319                    self.cull_resources =
1320                        Some(crate::renderer::indirect::CullResources::new(device));
1321                }
1322                resources.ensure_cull_instance_pipelines(device);
1323                for c in 0..effective_cascade_count {
1324                    resources.get_shadow_cull_instance_bind_group(device, c);
1325                }
1326
1327                let instance_count = self.cached_instance_data.len() as u32;
1328                let batch_count = self.instanced_batches.len() as u32;
1329
1330                if let (Some(aabb_buf), Some(meta_buf), Some(counter_buf)) = (
1331                    resources.instance_aabb_buf.as_ref(),
1332                    resources.batch_meta_buf.as_ref(),
1333                    resources.batch_counter_buf.as_ref(),
1334                ) {
1335                    let cull = self.cull_resources.as_ref().unwrap();
1336                    let mut shadow_cull_encoder =
1337                        device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1338                            label: Some("shadow_cull_encoder"),
1339                        });
1340                    for c in 0..effective_cascade_count {
1341                        if let (Some(shadow_vis_buf), Some(shadow_indirect_buf)) = (
1342                            resources.shadow_vis_bufs[c].as_ref(),
1343                            resources.shadow_indirect_bufs[c].as_ref(),
1344                        ) {
1345                            let cpu_frustum =
1346                                crate::camera::frustum::Frustum::from_view_proj(
1347                                    &cascade_view_projs[c],
1348                                );
1349                            let frustum_uniform = crate::resources::FrustumUniform {
1350                                planes: std::array::from_fn(|i| crate::resources::FrustumPlane {
1351                                    normal: cpu_frustum.planes[i].normal.into(),
1352                                    distance: cpu_frustum.planes[i].d,
1353                                }),
1354                                instance_count,
1355                                batch_count,
1356                                _pad: [0; 2],
1357                            };
1358                            cull.dispatch_shadow(
1359                                &mut shadow_cull_encoder,
1360                                device,
1361                                queue,
1362                                c,
1363                                &frustum_uniform,
1364                                aabb_buf,
1365                                meta_buf,
1366                                counter_buf,
1367                                shadow_vis_buf,
1368                                shadow_indirect_buf,
1369                                instance_count,
1370                                batch_count,
1371                            );
1372                        }
1373                    }
1374                    queue.submit(std::iter::once(shadow_cull_encoder.finish()));
1375                }
1376            }
1377
1378            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1379                label: Some("shadow_pass_encoder"),
1380            });
1381            {
1382                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1383                    label: Some("shadow_pass"),
1384                    color_attachments: &[],
1385                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1386                        view: &resources.shadow_map_view,
1387                        depth_ops: Some(wgpu::Operations {
1388                            load: wgpu::LoadOp::Clear(1.0),
1389                            store: wgpu::StoreOp::Store,
1390                        }),
1391                        stencil_ops: None,
1392                    }),
1393                    timestamp_writes: None,
1394                    occlusion_query_set: None,
1395                });
1396
1397                let mut shadow_draws = 0u32;
1398                let tile_px = tile_size as f32;
1399
1400                if self.use_instancing {
1401                    let use_shadow_indirect = self.gpu_culling_enabled
1402                        && resources.shadow_instanced_cull_pipeline.is_some()
1403                        && resources.shadow_vis_bufs[0].is_some();
1404
1405                    if use_shadow_indirect {
1406                        // GPU-culled indirect shadow path (Phase 4).
1407                        for cascade in 0..effective_cascade_count {
1408                            let tile_col = (cascade % 2) as f32;
1409                            let tile_row = (cascade / 2) as f32;
1410                            shadow_pass.set_viewport(
1411                                tile_col * tile_px,
1412                                tile_row * tile_px,
1413                                tile_px,
1414                                tile_px,
1415                                0.0,
1416                                1.0,
1417                            );
1418                            shadow_pass.set_scissor_rect(
1419                                (tile_col * tile_px) as u32,
1420                                (tile_row * tile_px) as u32,
1421                                tile_size,
1422                                tile_size,
1423                            );
1424
1425                            // Write cascade view-projection matrix.
1426                            queue.write_buffer(
1427                                resources.shadow_instanced_cascade_bufs[cascade]
1428                                    .as_ref()
1429                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1430                                0,
1431                                bytemuck::cast_slice(
1432                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1433                                ),
1434                            );
1435
1436                            let Some(pipeline) =
1437                                resources.shadow_instanced_cull_pipeline.as_ref()
1438                            else {
1439                                continue;
1440                            };
1441                            let Some(cascade_bg) =
1442                                resources.shadow_instanced_cascade_bgs[cascade].as_ref()
1443                            else {
1444                                continue;
1445                            };
1446                            let Some(inst_cull_bg) =
1447                                resources.shadow_cull_instance_bgs[cascade].as_ref()
1448                            else {
1449                                continue;
1450                            };
1451                            let Some(shadow_indirect_buf) =
1452                                resources.shadow_indirect_bufs[cascade].as_ref()
1453                            else {
1454                                continue;
1455                            };
1456
1457                            shadow_pass.set_pipeline(pipeline);
1458                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1459                            shadow_pass.set_bind_group(1, inst_cull_bg, &[]);
1460
1461                            for (bi, batch) in self.instanced_batches.iter().enumerate() {
1462                                if batch.is_transparent {
1463                                    continue;
1464                                }
1465                                let Some(mesh) = resources.mesh_store.get(batch.mesh_id) else {
1466                                    continue;
1467                                };
1468                                shadow_pass
1469                                    .set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1470                                shadow_pass.set_index_buffer(
1471                                    mesh.index_buffer.slice(..),
1472                                    wgpu::IndexFormat::Uint32,
1473                                );
1474                                shadow_pass.draw_indexed_indirect(
1475                                    shadow_indirect_buf,
1476                                    bi as u64 * 20,
1477                                );
1478                                shadow_draws += 1;
1479                            }
1480                        }
1481                    } else if let (Some(pipeline), Some(instance_bg)) = (
1482                        &resources.shadow_instanced_pipeline,
1483                        self.instanced_batches.first().and_then(|b| {
1484                            resources.instance_bind_groups.get(&(
1485                                b.texture_id.unwrap_or(u64::MAX),
1486                                b.normal_map_id.unwrap_or(u64::MAX),
1487                                b.ao_map_id.unwrap_or(u64::MAX),
1488                            ))
1489                        }),
1490                    ) {
1491                        // Direct draw shadow path (fallback when GPU culling is off).
1492                        for cascade in 0..effective_cascade_count {
1493                            let tile_col = (cascade % 2) as f32;
1494                            let tile_row = (cascade / 2) as f32;
1495                            shadow_pass.set_viewport(
1496                                tile_col * tile_px,
1497                                tile_row * tile_px,
1498                                tile_px,
1499                                tile_px,
1500                                0.0,
1501                                1.0,
1502                            );
1503                            shadow_pass.set_scissor_rect(
1504                                (tile_col * tile_px) as u32,
1505                                (tile_row * tile_px) as u32,
1506                                tile_size,
1507                                tile_size,
1508                            );
1509
1510                            shadow_pass.set_pipeline(pipeline);
1511
1512                            queue.write_buffer(
1513                                resources.shadow_instanced_cascade_bufs[cascade]
1514                                    .as_ref()
1515                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1516                                0,
1517                                bytemuck::cast_slice(
1518                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1519                                ),
1520                            );
1521
1522                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
1523                                .as_ref()
1524                                .expect("shadow_instanced_cascade_bgs not allocated");
1525                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1526                            shadow_pass.set_bind_group(1, instance_bg, &[]);
1527
1528                            for batch in &self.instanced_batches {
1529                                if batch.is_transparent {
1530                                    continue;
1531                                }
1532                                let Some(mesh) = resources
1533                                    .mesh_store
1534                                    .get(batch.mesh_id)
1535                                else {
1536                                    continue;
1537                                };
1538                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1539                                shadow_pass.set_index_buffer(
1540                                    mesh.index_buffer.slice(..),
1541                                    wgpu::IndexFormat::Uint32,
1542                                );
1543                                shadow_pass.draw_indexed(
1544                                    0..mesh.index_count,
1545                                    0,
1546                                    batch.instance_offset
1547                                        ..batch.instance_offset + batch.instance_count,
1548                                );
1549                                shadow_draws += 1;
1550                            }
1551                        }
1552                    }
1553                } else {
1554                    for cascade in 0..effective_cascade_count {
1555                        let tile_col = (cascade % 2) as f32;
1556                        let tile_row = (cascade / 2) as f32;
1557                        shadow_pass.set_viewport(
1558                            tile_col * tile_px,
1559                            tile_row * tile_px,
1560                            tile_px,
1561                            tile_px,
1562                            0.0,
1563                            1.0,
1564                        );
1565                        shadow_pass.set_scissor_rect(
1566                            (tile_col * tile_px) as u32,
1567                            (tile_row * tile_px) as u32,
1568                            tile_size,
1569                            tile_size,
1570                        );
1571
1572                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
1573                        shadow_pass.set_bind_group(
1574                            0,
1575                            &resources.shadow_bind_group,
1576                            &[cascade as u32 * 256],
1577                        );
1578
1579                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
1580                            &cascade_view_projs[cascade],
1581                        );
1582
1583                        for item in scene_items.iter() {
1584                            if !item.visible {
1585                                continue;
1586                            }
1587                            if item.material.opacity < 1.0 {
1588                                continue;
1589                            }
1590                            let Some(mesh) = resources
1591                                .mesh_store
1592                                .get(item.mesh_id)
1593                            else {
1594                                continue;
1595                            };
1596
1597                            let world_aabb = mesh
1598                                .aabb
1599                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1600                            if cascade_frustum.cull_aabb(&world_aabb) {
1601                                continue;
1602                            }
1603
1604                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1605                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1606                            shadow_pass.set_index_buffer(
1607                                mesh.index_buffer.slice(..),
1608                                wgpu::IndexFormat::Uint32,
1609                            );
1610                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1611                            shadow_draws += 1;
1612                        }
1613                    }
1614                }
1615                drop(shadow_pass);
1616                self.last_stats.shadow_draw_calls = shadow_draws;
1617            }
1618            queue.submit(std::iter::once(encoder.finish()));
1619        }
1620    }
1621
1622    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
1623    ///
1624    /// Call once per viewport per frame, after `prepare_scene_internal`.
1625    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
1626    pub(super) fn prepare_viewport_internal(
1627        &mut self,
1628        device: &wgpu::Device,
1629        queue: &wgpu::Queue,
1630        frame: &FrameData,
1631        viewport_fx: &ViewportEffects<'_>,
1632    ) {
1633        // Ensure a per-viewport camera slot exists for this viewport index.
1634        // Must happen before the `resources` borrow below.
1635        self.ensure_viewport_slot(device, frame.camera.viewport_index);
1636
1637        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1638            SurfaceSubmission::Flat(items) => items.as_ref(),
1639        };
1640
1641        // Capture before the resources mutable borrow so it's accessible inside the block.
1642        let gp_cascade0_mat = self.last_cascade0_shadow_mat.to_cols_array_2d();
1643
1644        {
1645            let resources = &mut self.resources;
1646
1647            // Upload clip planes + clip volume uniforms from clip_objects.
1648            {
1649                let mut planes = [[0.0f32; 4]; 6];
1650                let mut count = 0u32;
1651                let mut clip_vol_uniform: ClipVolumeUniform = bytemuck::Zeroable::zeroed(); // volume_type=0
1652
1653                for obj in viewport_fx
1654                    .clip_objects
1655                    .iter()
1656                    .filter(|o| o.enabled && o.clip_geometry)
1657                {
1658                    match obj.shape {
1659                        ClipShape::Plane {
1660                            normal, distance, ..
1661                        } if count < 6 => {
1662                            planes[count as usize] = [normal[0], normal[1], normal[2], distance];
1663                            count += 1;
1664                        }
1665                        ClipShape::Box {
1666                            center,
1667                            half_extents,
1668                            orientation,
1669                        } if clip_vol_uniform.volume_type == 0 => {
1670                            clip_vol_uniform.volume_type = 2;
1671                            clip_vol_uniform.box_center = center;
1672                            clip_vol_uniform.box_half_extents = half_extents;
1673                            clip_vol_uniform.box_col0 = orientation[0];
1674                            clip_vol_uniform.box_col1 = orientation[1];
1675                            clip_vol_uniform.box_col2 = orientation[2];
1676                        }
1677                        ClipShape::Sphere { center, radius }
1678                            if clip_vol_uniform.volume_type == 0 =>
1679                        {
1680                            clip_vol_uniform.volume_type = 3;
1681                            clip_vol_uniform.sphere_center = center;
1682                            clip_vol_uniform.sphere_radius = radius;
1683                        }
1684                        _ => {}
1685                    }
1686                }
1687
1688                let clip_uniform = ClipPlanesUniform {
1689                    planes,
1690                    count,
1691                    _pad0: 0,
1692                    viewport_width: frame.camera.viewport_size[0].max(1.0),
1693                    viewport_height: frame.camera.viewport_size[1].max(1.0),
1694                };
1695                // Write to per-viewport slot buffer.
1696                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1697                    queue.write_buffer(
1698                        &slot.clip_planes_buf,
1699                        0,
1700                        bytemuck::cast_slice(&[clip_uniform]),
1701                    );
1702                    queue.write_buffer(
1703                        &slot.clip_volume_buf,
1704                        0,
1705                        bytemuck::cast_slice(&[clip_vol_uniform]),
1706                    );
1707                }
1708                // Also write to shared buffers for legacy single-viewport callers.
1709                queue.write_buffer(
1710                    &resources.clip_planes_uniform_buf,
1711                    0,
1712                    bytemuck::cast_slice(&[clip_uniform]),
1713                );
1714                queue.write_buffer(
1715                    &resources.clip_volume_uniform_buf,
1716                    0,
1717                    bytemuck::cast_slice(&[clip_vol_uniform]),
1718                );
1719            }
1720
1721            // Upload camera uniform to per-viewport slot buffer.
1722            let camera_uniform = frame.camera.render_camera.camera_uniform();
1723            // Write to shared buffer for legacy single-viewport callers.
1724            queue.write_buffer(
1725                &resources.camera_uniform_buf,
1726                0,
1727                bytemuck::cast_slice(&[camera_uniform]),
1728            );
1729            // Write to the per-viewport slot buffer.
1730            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1731                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1732            }
1733
1734            // Upload grid uniform (full-screen analytical shader : no vertex buffers needed).
1735            if frame.viewport.show_grid {
1736                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1737                if !eye.is_finite() {
1738                    tracing::warn!(
1739                        eye_x = eye.x,
1740                        eye_y = eye.y,
1741                        eye_z = eye.z,
1742                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1743                    );
1744                } else {
1745                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1746
1747                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1748                        (frame.viewport.grid_cell_size, 1.0_f32)
1749                    } else {
1750                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1751                        let world_per_pixel =
1752                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1753                                / frame.camera.viewport_size[1].max(1.0);
1754                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1755                        let mut s = 1.0_f32;
1756                        let mut iters = 0u32;
1757                        while s < target {
1758                            s *= 10.0;
1759                            iters += 1;
1760                        }
1761                        let ratio = (target / s).clamp(0.0, 1.0);
1762                        let fade = if ratio < 0.5 {
1763                            1.0_f32
1764                        } else {
1765                            let t = (ratio - 0.5) * 2.0;
1766                            1.0 - t * t * (3.0 - 2.0 * t)
1767                        };
1768                        tracing::debug!(
1769                            eye_z = eye.z,
1770                            vertical_depth,
1771                            world_per_pixel,
1772                            target,
1773                            spacing = s,
1774                            lod_iters = iters,
1775                            ratio,
1776                            minor_fade = fade,
1777                            "grid LOD"
1778                        );
1779                        (s, fade)
1780                    };
1781
1782                    let spacing_major = spacing * 10.0;
1783                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1784                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1785                    tracing::debug!(
1786                        spacing_minor = spacing,
1787                        spacing_major,
1788                        snap_x,
1789                        snap_y,
1790                        eye_x = eye.x,
1791                        eye_y = eye.y,
1792                        eye_z = eye.z,
1793                        "grid snap"
1794                    );
1795
1796                    let orient = frame.camera.render_camera.orientation;
1797                    let right = orient * glam::Vec3::X;
1798                    let up = orient * glam::Vec3::Y;
1799                    let back = orient * glam::Vec3::Z;
1800                    let cam_to_world = [
1801                        [right.x, right.y, right.z, 0.0_f32],
1802                        [up.x, up.y, up.z, 0.0_f32],
1803                        [back.x, back.y, back.z, 0.0_f32],
1804                    ];
1805                    let aspect =
1806                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1807                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1808
1809                    let uniform = GridUniform {
1810                        view_proj: view_proj_mat,
1811                        cam_to_world,
1812                        tan_half_fov,
1813                        aspect,
1814                        _pad_ivp: [0.0; 2],
1815                        eye_pos: frame.camera.render_camera.eye_position,
1816                        grid_z: frame.viewport.grid_z,
1817                        spacing_minor: spacing,
1818                        spacing_major,
1819                        snap_origin: [snap_x, snap_y],
1820                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1821                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1822                    };
1823                    // Write to per-viewport slot buffer.
1824                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1825                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1826                    }
1827                    // Also write to shared buffer for legacy callers.
1828                    queue.write_buffer(
1829                        &resources.grid_uniform_buf,
1830                        0,
1831                        bytemuck::cast_slice(&[uniform]),
1832                    );
1833                }
1834            }
1835            // ------------------------------------------------------------------
1836            // Ground plane uniform upload.
1837            // ------------------------------------------------------------------
1838            {
1839                let gp = &viewport_fx.ground_plane;
1840                let mode_u32: u32 = match gp.mode {
1841                    crate::renderer::types::GroundPlaneMode::None => 0,
1842                    crate::renderer::types::GroundPlaneMode::ShadowOnly => 1,
1843                    crate::renderer::types::GroundPlaneMode::Tile => 2,
1844                    crate::renderer::types::GroundPlaneMode::SolidColor => 3,
1845                };
1846                let orient = frame.camera.render_camera.orientation;
1847                let right = orient * glam::Vec3::X;
1848                let up = orient * glam::Vec3::Y;
1849                let back = orient * glam::Vec3::Z;
1850                let aspect = frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1851                let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1852                let vp = frame.camera.render_camera.view_proj().to_cols_array_2d();
1853                let gp_uniform = crate::resources::GroundPlaneUniform {
1854                    view_proj: vp,
1855                    cam_right: [right.x, right.y, right.z, 0.0],
1856                    cam_up: [up.x, up.y, up.z, 0.0],
1857                    cam_back: [back.x, back.y, back.z, 0.0],
1858                    eye_pos: frame.camera.render_camera.eye_position,
1859                    height: gp.height,
1860                    color: gp.color,
1861                    shadow_color: gp.shadow_color,
1862                    light_vp: gp_cascade0_mat,
1863                    tan_half_fov,
1864                    aspect,
1865                    tile_size: gp.tile_size,
1866                    shadow_bias: 0.002,
1867                    mode: mode_u32,
1868                    shadow_opacity: gp.shadow_opacity,
1869                    _pad: [0.0; 2],
1870                };
1871                queue.write_buffer(
1872                    &resources.ground_plane_uniform_buf,
1873                    0,
1874                    bytemuck::cast_slice(&[gp_uniform]),
1875                );
1876            }
1877        } // `resources` mutable borrow dropped here.
1878
1879        // ------------------------------------------------------------------
1880        // Build per-viewport interaction state into local variables.
1881        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1882        // the slot borrow that follows.
1883        // ------------------------------------------------------------------
1884
1885        let vp_idx = frame.camera.viewport_index;
1886
1887        // Outline mask buffers for selected objects (one per selected object).
1888        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1889        if frame.interaction.outline_selected {
1890            let resources = &self.resources;
1891            for item in scene_items {
1892                if !item.visible || !item.selected {
1893                    continue;
1894                }
1895                let uniform = OutlineUniform {
1896                    model: item.model,
1897                    color: [0.0; 4], // unused by mask shader
1898                    pixel_offset: 0.0,
1899                    _pad: [0.0; 3],
1900                };
1901                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1902                    label: Some("outline_mask_uniform_buf"),
1903                    size: std::mem::size_of::<OutlineUniform>() as u64,
1904                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1905                    mapped_at_creation: false,
1906                });
1907                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1908                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1909                    label: Some("outline_mask_object_bg"),
1910                    layout: &resources.outline_bind_group_layout,
1911                    entries: &[wgpu::BindGroupEntry {
1912                        binding: 0,
1913                        resource: buf.as_entire_binding(),
1914                    }],
1915                });
1916                outline_object_buffers.push(OutlineObjectBuffers {
1917                    mesh_id: item.mesh_id,
1918                    two_sided: item.material.is_two_sided(),
1919                    _mask_uniform_buf: buf,
1920                    mask_bind_group: bg,
1921                });
1922            }
1923        }
1924
1925        // X-ray buffers for selected objects.
1926        let mut xray_object_buffers: Vec<(crate::resources::mesh_store::MeshId, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1927        if frame.interaction.xray_selected {
1928            let resources = &self.resources;
1929            for item in scene_items {
1930                if !item.visible || !item.selected {
1931                    continue;
1932                }
1933                let uniform = OutlineUniform {
1934                    model: item.model,
1935                    color: frame.interaction.xray_color,
1936                    pixel_offset: 0.0,
1937                    _pad: [0.0; 3],
1938                };
1939                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1940                    label: Some("xray_uniform_buf"),
1941                    size: std::mem::size_of::<OutlineUniform>() as u64,
1942                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1943                    mapped_at_creation: false,
1944                });
1945                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1946                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1947                    label: Some("xray_object_bg"),
1948                    layout: &resources.outline_bind_group_layout,
1949                    entries: &[wgpu::BindGroupEntry {
1950                        binding: 0,
1951                        resource: buf.as_entire_binding(),
1952                    }],
1953                });
1954                xray_object_buffers.push((item.mesh_id, buf, bg));
1955            }
1956        }
1957
1958        // Constraint guide lines.
1959        let mut constraint_line_buffers = Vec::new();
1960        for overlay in &frame.interaction.constraint_overlays {
1961            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
1962        }
1963
1964        // Clip plane overlays : generated automatically from clip_objects with a color set.
1965        let mut clip_plane_fill_buffers = Vec::new();
1966        let mut clip_plane_line_buffers = Vec::new();
1967        for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1968            // Skip if neither fill nor edge color is set.
1969            if obj.color.is_none() && obj.edge_color.is_none() {
1970                continue;
1971            }
1972            if let ClipShape::Plane {
1973                normal, distance, ..
1974            } = obj.shape
1975            {
1976                let n = glam::Vec3::from(normal);
1977                // Shader plane equation: dot(p, n) + distance = 0, so the plane
1978                // sits at -n * distance from the origin.
1979                let center = n * (-distance);
1980                let active = obj.active;
1981                let hovered = obj.hovered || active;
1982
1983                // Fill quad: derived from `color`; transparent if not set.
1984                let fill_color = if let Some(base_color) = obj.color {
1985                    if active {
1986                        [
1987                            base_color[0] * 0.5,
1988                            base_color[1] * 0.5,
1989                            base_color[2] * 0.5,
1990                            base_color[3] * 0.5,
1991                        ]
1992                    } else if hovered {
1993                        [
1994                            base_color[0] * 0.8,
1995                            base_color[1] * 0.8,
1996                            base_color[2] * 0.8,
1997                            base_color[3] * 0.6,
1998                        ]
1999                    } else {
2000                        [
2001                            base_color[0] * 0.5,
2002                            base_color[1] * 0.5,
2003                            base_color[2] * 0.5,
2004                            base_color[3] * 0.3,
2005                        ]
2006                    }
2007                } else {
2008                    [0.0, 0.0, 0.0, 0.0]
2009                };
2010
2011                // Border edge: use `edge_color` when set, otherwise derive from `color`.
2012                let border_base = obj
2013                    .edge_color
2014                    .or(obj.color)
2015                    .unwrap_or([1.0, 1.0, 1.0, 1.0]);
2016                let border_color = if active {
2017                    [border_base[0], border_base[1], border_base[2], 0.9]
2018                } else if hovered {
2019                    [border_base[0], border_base[1], border_base[2], 0.8]
2020                } else {
2021                    [
2022                        border_base[0] * 0.9,
2023                        border_base[1] * 0.9,
2024                        border_base[2] * 0.9,
2025                        0.6,
2026                    ]
2027                };
2028
2029                let overlay = crate::interaction::clip_plane::ClipPlaneOverlay {
2030                    center,
2031                    normal: n,
2032                    extent: obj.extent,
2033                    fill_color,
2034                    border_color,
2035                    _hovered: hovered,
2036                    _active: active,
2037                };
2038                if obj.color.is_some() {
2039                    clip_plane_fill_buffers.push(
2040                        self.resources
2041                            .create_clip_plane_fill_overlay(device, &overlay),
2042                    );
2043                }
2044                clip_plane_line_buffers.push(
2045                    self.resources
2046                        .create_clip_plane_line_overlay(device, &overlay),
2047                );
2048            } else {
2049                // Box/Sphere: generate wireframe polyline.
2050                // ensure_polyline_pipeline must be called before upload_polyline; it is a
2051                // no-op if already initialised, so calling it here is always safe.
2052                let base_color = obj.color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
2053                self.resources.ensure_polyline_pipeline(device);
2054                match obj.shape {
2055                    ClipShape::Box {
2056                        center,
2057                        half_extents,
2058                        orientation,
2059                    } => {
2060                        let polyline =
2061                            clip_box_outline(center, half_extents, orientation, base_color);
2062                        let vp_size = frame.camera.viewport_size;
2063                        let gpu = self
2064                            .resources
2065                            .upload_polyline(device, queue, &polyline, vp_size);
2066                        self.polyline_gpu_data.push(gpu);
2067                    }
2068                    ClipShape::Sphere { center, radius } => {
2069                        let polyline = clip_sphere_outline(center, radius, base_color);
2070                        let vp_size = frame.camera.viewport_size;
2071                        let gpu = self
2072                            .resources
2073                            .upload_polyline(device, queue, &polyline, vp_size);
2074                        self.polyline_gpu_data.push(gpu);
2075                    }
2076                    _ => {}
2077                }
2078            }
2079        }
2080
2081        // Cap geometry for section-view cross-section fill.
2082        let mut cap_buffers = Vec::new();
2083        if viewport_fx.cap_fill_enabled {
2084            for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
2085                if let ClipShape::Plane {
2086                    normal,
2087                    distance,
2088                    cap_color,
2089                } = obj.shape
2090                {
2091                    let plane_n = glam::Vec3::from(normal);
2092                    for item in scene_items.iter().filter(|i| i.visible) {
2093                        let Some(mesh) = self
2094                            .resources
2095                            .mesh_store
2096                            .get(item.mesh_id)
2097                        else {
2098                            continue;
2099                        };
2100                        let model = glam::Mat4::from_cols_array_2d(&item.model);
2101                        let world_aabb = mesh.aabb.transformed(&model);
2102                        if !world_aabb.intersects_plane(plane_n, distance) {
2103                            continue;
2104                        }
2105                        let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices)
2106                        else {
2107                            continue;
2108                        };
2109                        if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
2110                            pos, idx, &model, plane_n, distance,
2111                        ) {
2112                            let bc = item.material.base_color;
2113                            let color = cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
2114                            let buf = self.resources.upload_cap_geometry(device, &cap, color);
2115                            cap_buffers.push(buf);
2116                        }
2117                    }
2118                }
2119            }
2120        }
2121
2122        // Axes indicator geometry (built here, written to slot buffer below).
2123        let axes_verts = if frame.viewport.show_axes_indicator
2124            && frame.camera.viewport_size[0] > 0.0
2125            && frame.camera.viewport_size[1] > 0.0
2126        {
2127            let verts = crate::widgets::axes_indicator::build_axes_geometry(
2128                frame.camera.viewport_size[0],
2129                frame.camera.viewport_size[1],
2130                frame.camera.render_camera.orientation,
2131            );
2132            if verts.is_empty() { None } else { Some(verts) }
2133        } else {
2134            None
2135        };
2136
2137        // Gizmo mesh + uniform (built here, written to slot buffers below).
2138        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
2139            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
2140                frame.interaction.gizmo_mode,
2141                frame.interaction.gizmo_hovered,
2142                frame.interaction.gizmo_space_orientation,
2143            );
2144            (verts, indices, model)
2145        });
2146
2147        // ------------------------------------------------------------------
2148        // Assign all interaction state to the per-viewport slot.
2149        // ------------------------------------------------------------------
2150        {
2151            let slot = &mut self.viewport_slots[vp_idx];
2152            slot.outline_object_buffers = outline_object_buffers;
2153            slot.xray_object_buffers = xray_object_buffers;
2154            slot.constraint_line_buffers = constraint_line_buffers;
2155            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
2156            slot.clip_plane_line_buffers = clip_plane_line_buffers;
2157            slot.cap_buffers = cap_buffers;
2158
2159            // Axes: resize buffer if needed, then upload.
2160            if let Some(verts) = axes_verts {
2161                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
2162                if byte_size > slot.axes_vertex_buffer.size() {
2163                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2164                        label: Some("vp_axes_vertex_buf"),
2165                        size: byte_size,
2166                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2167                        mapped_at_creation: false,
2168                    });
2169                }
2170                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
2171                slot.axes_vertex_count = verts.len() as u32;
2172            } else {
2173                slot.axes_vertex_count = 0;
2174            }
2175
2176            // Gizmo: resize buffers if needed, then upload mesh + uniform.
2177            if let Some((verts, indices, model)) = gizmo_update {
2178                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
2179                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
2180                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
2181                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2182                        label: Some("vp_gizmo_vertex_buf"),
2183                        size: vert_bytes.len() as u64,
2184                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2185                        mapped_at_creation: false,
2186                    });
2187                }
2188                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
2189                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2190                        label: Some("vp_gizmo_index_buf"),
2191                        size: idx_bytes.len() as u64,
2192                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
2193                        mapped_at_creation: false,
2194                    });
2195                }
2196                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
2197                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
2198                slot.gizmo_index_count = indices.len() as u32;
2199                let uniform = crate::interaction::gizmo::GizmoUniform {
2200                    model: model.to_cols_array_2d(),
2201                };
2202                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
2203            }
2204        }
2205
2206        // ------------------------------------------------------------------
2207        // Outline offscreen pass : screen-space edge detection.
2208        //
2209        // 1. Render selected objects to an R8 mask texture (white on black).
2210        // 2. Run a fullscreen edge-detection pass reading the mask and writing
2211        //    an anti-aliased outline ring to the outline color texture.
2212        //
2213        // The outline color texture is later composited onto the main target
2214        // by the composite pass in paint()/render().
2215        // ------------------------------------------------------------------
2216        if frame.interaction.outline_selected
2217            && !self.viewport_slots[vp_idx]
2218                .outline_object_buffers
2219                .is_empty()
2220        {
2221            let w = frame.camera.viewport_size[0] as u32;
2222            let h = frame.camera.viewport_size[1] as u32;
2223
2224            // Ensure per-viewport HDR state exists (provides outline textures).
2225            self.ensure_viewport_hdr(
2226                device,
2227                queue,
2228                vp_idx,
2229                w.max(1),
2230                h.max(1),
2231                frame.effects.post_process.ssaa_factor.max(1),
2232            );
2233
2234            // Write edge-detection uniform (color, radius, viewport size).
2235            {
2236                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2237                let edge_uniform = OutlineEdgeUniform {
2238                    color: frame.interaction.outline_color,
2239                    radius: frame.interaction.outline_width_px,
2240                    viewport_w: w as f32,
2241                    viewport_h: h as f32,
2242                    _pad: 0.0,
2243                };
2244                queue.write_buffer(
2245                    &slot_hdr.outline_edge_uniform_buf,
2246                    0,
2247                    bytemuck::cast_slice(&[edge_uniform]),
2248                );
2249            }
2250
2251            // Extract raw pointers for slot fields needed inside the render
2252            // passes alongside &self.resources borrows.
2253            let slot_ref = &self.viewport_slots[vp_idx];
2254            let outlines_ptr =
2255                &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
2256            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
2257            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
2258            let mask_view_ptr = &slot_hdr.outline_mask_view as *const wgpu::TextureView;
2259            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
2260            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
2261            let edge_bg_ptr = &slot_hdr.outline_edge_bind_group as *const wgpu::BindGroup;
2262            // SAFETY: slot fields remain valid for the duration of this function;
2263            // no other code modifies these fields here.
2264            let (outlines, camera_bg, mask_view, color_view, depth_view, edge_bg) = unsafe {
2265                (
2266                    &*outlines_ptr,
2267                    &*camera_bg_ptr,
2268                    &*mask_view_ptr,
2269                    &*color_view_ptr,
2270                    &*depth_view_ptr,
2271                    &*edge_bg_ptr,
2272                )
2273            };
2274
2275            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2276                label: Some("outline_offscreen_encoder"),
2277            });
2278
2279            // Pass 1: render selected objects to R8 mask texture.
2280            {
2281                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2282                    label: Some("outline_mask_pass"),
2283                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2284                        view: mask_view,
2285                        resolve_target: None,
2286                        ops: wgpu::Operations {
2287                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2288                            store: wgpu::StoreOp::Store,
2289                        },
2290                        depth_slice: None,
2291                    })],
2292                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2293                        view: depth_view,
2294                        depth_ops: Some(wgpu::Operations {
2295                            load: wgpu::LoadOp::Clear(1.0),
2296                            store: wgpu::StoreOp::Discard,
2297                        }),
2298                        stencil_ops: None,
2299                    }),
2300                    timestamp_writes: None,
2301                    occlusion_query_set: None,
2302                });
2303
2304                pass.set_bind_group(0, camera_bg, &[]);
2305                for outlined in outlines {
2306                    let Some(mesh) = self
2307                        .resources
2308                        .mesh_store
2309                        .get(outlined.mesh_id)
2310                    else {
2311                        continue;
2312                    };
2313                    let pipeline = if outlined.two_sided {
2314                        &self.resources.outline_mask_two_sided_pipeline
2315                    } else {
2316                        &self.resources.outline_mask_pipeline
2317                    };
2318                    pass.set_pipeline(pipeline);
2319                    pass.set_bind_group(1, &outlined.mask_bind_group, &[]);
2320                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2321                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2322                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
2323                }
2324            }
2325
2326            // Pass 2: fullscreen edge detection (reads mask, writes color).
2327            {
2328                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2329                    label: Some("outline_edge_pass"),
2330                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2331                        view: color_view,
2332                        resolve_target: None,
2333                        ops: wgpu::Operations {
2334                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2335                            store: wgpu::StoreOp::Store,
2336                        },
2337                        depth_slice: None,
2338                    })],
2339                    depth_stencil_attachment: None,
2340                    timestamp_writes: None,
2341                    occlusion_query_set: None,
2342                });
2343                pass.set_pipeline(&self.resources.outline_edge_pipeline);
2344                pass.set_bind_group(0, edge_bg, &[]);
2345                pass.draw(0..3, 0..1);
2346            }
2347
2348            queue.submit(std::iter::once(encoder.finish()));
2349        }
2350
2351        // ------------------------------------------------------------------
2352        // Sub-object highlight prepare: build GPU geometry from sub-selection
2353        // snapshot when the version has changed since the last frame.
2354        // ------------------------------------------------------------------
2355        {
2356            let w = frame.camera.viewport_size[0];
2357            let h = frame.camera.viewport_size[1];
2358            if let Some(sel_ref) = &frame.interaction.sub_selection {
2359                let needs_rebuild = {
2360                    let slot = &self.viewport_slots[vp_idx];
2361                    slot.sub_highlight_generation != sel_ref.version
2362                        || slot.sub_highlight.is_none()
2363                };
2364                if needs_rebuild {
2365                    self.resources.ensure_sub_highlight_pipelines(device);
2366                    let data = self.resources.build_sub_highlight(
2367                        device,
2368                        queue,
2369                        sel_ref,
2370                        frame.interaction.sub_highlight_face_fill_color,
2371                        frame.interaction.sub_highlight_edge_color,
2372                        frame.interaction.sub_highlight_edge_width_px,
2373                        frame.interaction.sub_highlight_vertex_size_px,
2374                        w,
2375                        h,
2376                    );
2377                    let slot = &mut self.viewport_slots[vp_idx];
2378                    slot.sub_highlight = Some(data);
2379                    slot.sub_highlight_generation = sel_ref.version;
2380                }
2381            } else {
2382                let slot = &mut self.viewport_slots[vp_idx];
2383                slot.sub_highlight = None;
2384                slot.sub_highlight_generation = u64::MAX;
2385            }
2386        }
2387
2388        // ---------------------------------------------------------------
2389        // Overlay labels
2390        // ---------------------------------------------------------------
2391        self.label_gpu_data = None;
2392        if !frame.overlays.labels.is_empty() {
2393            self.resources.ensure_overlay_text_pipeline(device);
2394            let vp_w = frame.camera.viewport_size[0];
2395            let vp_h = frame.camera.viewport_size[1];
2396            if vp_w > 0.0 && vp_h > 0.0 {
2397                let view = &frame.camera.render_camera.view;
2398                let proj = &frame.camera.render_camera.projection;
2399
2400                // Sort by z_order for correct draw ordering.
2401                let mut sorted_labels: Vec<&crate::renderer::types::LabelItem> =
2402                    frame.overlays.labels.iter().collect();
2403                sorted_labels.sort_by_key(|l| l.z_order);
2404
2405                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2406
2407                for label in &sorted_labels {
2408                    if label.text.is_empty() || label.opacity <= 0.0 {
2409                        continue;
2410                    }
2411
2412                    // Resolve screen position from anchor.
2413                    let screen_pos = if let Some(sa) = label.screen_anchor {
2414                        Some(sa)
2415                    } else if let Some(wa) = label.world_anchor {
2416                        project_to_screen(wa, view, proj, vp_w, vp_h)
2417                    } else {
2418                        continue;
2419                    };
2420                    let Some(anchor_px) = screen_pos else {
2421                        continue;
2422                    };
2423
2424                    let opacity = label.opacity.clamp(0.0, 1.0);
2425
2426                    // Layout text (with optional word wrapping).
2427                    let layout = if let Some(max_w) = label.max_width {
2428                        self.resources.glyph_atlas.layout_text_wrapped(
2429                            &label.text,
2430                            label.font_size,
2431                            label.font,
2432                            max_w,
2433                            device,
2434                        )
2435                    } else {
2436                        self.resources.glyph_atlas.layout_text(
2437                            &label.text,
2438                            label.font_size,
2439                            label.font,
2440                            device,
2441                        )
2442                    };
2443
2444                    // Compute ascent so glyphs are positioned below the anchor.
2445                    let font_index = label.font.map_or(0, |h| h.0);
2446                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, label.font_size);
2447
2448                    // Horizontal alignment.
2449                    let align_offset = match label.anchor_align {
2450                        crate::renderer::types::LabelAnchor::Leading => 6.0,
2451                        crate::renderer::types::LabelAnchor::Center => -layout.total_width * 0.5,
2452                        crate::renderer::types::LabelAnchor::Trailing => -layout.total_width - 6.0,
2453                    };
2454
2455                    // Text origin with alignment + user offset.
2456                    let text_x = anchor_px[0] + align_offset + label.offset[0];
2457                    let text_y = anchor_px[1] - layout.height * 0.5 + label.offset[1];
2458
2459                    // Background box (drawn first, behind text).
2460                    if label.background {
2461                        let pad = label.padding;
2462                        let bx0 = text_x - pad;
2463                        let by0 = text_y - pad;
2464                        let bx1 = text_x + layout.total_width + pad;
2465                        let by1 = text_y + layout.height + pad;
2466                        let bg_color = apply_opacity(label.background_color, opacity);
2467                        if label.border_radius > 0.0 {
2468                            emit_rounded_quad(
2469                                &mut verts,
2470                                bx0, by0, bx1, by1,
2471                                label.border_radius,
2472                                bg_color,
2473                                vp_w, vp_h,
2474                            );
2475                        } else {
2476                            emit_solid_quad(
2477                                &mut verts,
2478                                bx0, by0, bx1, by1,
2479                                bg_color,
2480                                vp_w, vp_h,
2481                            );
2482                        }
2483                    }
2484
2485                    // Leader line.
2486                    if label.leader_line {
2487                        if let Some(wa) = label.world_anchor {
2488                            let world_px = project_to_screen(wa, view, proj, vp_w, vp_h);
2489                            if let Some(wp) = world_px {
2490                                emit_line_quad(
2491                                    &mut verts,
2492                                    wp[0], wp[1],
2493                                    text_x, text_y + layout.height * 0.5,
2494                                    1.5,
2495                                    apply_opacity(label.leader_color, opacity),
2496                                    vp_w, vp_h,
2497                                );
2498                            }
2499                        }
2500                    }
2501
2502                    // Glyph quads.
2503                    let text_color = apply_opacity(label.color, opacity);
2504                    for gq in &layout.quads {
2505                        let gx = text_x + gq.pos[0];
2506                        let gy = text_y + ascent + gq.pos[1];
2507                        emit_textured_quad(
2508                            &mut verts,
2509                            gx, gy,
2510                            gx + gq.size[0], gy + gq.size[1],
2511                            gq.uv_min, gq.uv_max,
2512                            text_color,
2513                            vp_w, vp_h,
2514                        );
2515                    }
2516                }
2517
2518                // Upload atlas if new glyphs were rasterized.
2519                self.resources.glyph_atlas.upload_if_dirty(queue);
2520
2521                if !verts.is_empty() {
2522                    let vertex_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2523                        label: Some("overlay_label_vbuf"),
2524                        contents: bytemuck::cast_slice(&verts),
2525                        usage: wgpu::BufferUsages::VERTEX,
2526                    });
2527                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2528                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2529                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2530                        label: Some("overlay_label_bg"),
2531                        layout: bgl,
2532                        entries: &[
2533                            wgpu::BindGroupEntry {
2534                                binding: 0,
2535                                resource: wgpu::BindingResource::TextureView(
2536                                    &self.resources.glyph_atlas.view,
2537                                ),
2538                            },
2539                            wgpu::BindGroupEntry {
2540                                binding: 1,
2541                                resource: wgpu::BindingResource::Sampler(sampler),
2542                            },
2543                        ],
2544                    });
2545                    self.label_gpu_data = Some(crate::resources::LabelGpuData {
2546                        vertex_buf,
2547                        vertex_count: verts.len() as u32,
2548                        bind_group,
2549                    });
2550                }
2551            }
2552        }
2553
2554        // ---------------------------------------------------------------
2555        // Scalar bars
2556        // ---------------------------------------------------------------
2557        self.scalar_bar_gpu_data = None;
2558        if !frame.overlays.scalar_bars.is_empty() {
2559            self.resources.ensure_overlay_text_pipeline(device);
2560            let vp_w = frame.camera.viewport_size[0];
2561            let vp_h = frame.camera.viewport_size[1];
2562            if vp_w > 0.0 && vp_h > 0.0 {
2563                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2564
2565                for bar in &frame.overlays.scalar_bars {
2566                    // Clone the LUT immediately so the immutable borrow on self.resources
2567                    // is released before the mutable glyph_atlas borrow below.
2568                    let Some(lut) = self.resources.get_colormap_rgba(bar.colormap_id).map(|l| l.to_vec()) else {
2569                        continue;
2570                    };
2571
2572                    let is_vertical = matches!(
2573                        bar.orientation,
2574                        crate::renderer::types::ScalarBarOrientation::Vertical
2575                    );
2576                    let reversed = bar.ticks_reversed;
2577
2578                    // Effective font sizes.
2579                    let tick_fs = bar.font_size;
2580                    let title_fs = bar.title_font_size.unwrap_or(bar.font_size);
2581                    let font_index = bar.font.map_or(0, |h| h.0);
2582
2583                    // Actual pixel dimensions of the gradient strip.
2584                    let (strip_w, strip_h) = if is_vertical {
2585                        (bar.bar_width_px, bar.bar_length_px)
2586                    } else {
2587                        (bar.bar_length_px, bar.bar_width_px)
2588                    };
2589
2590                    // Pre-compute tick texts and their widths so the background box
2591                    // can be sized to cover the tick labels.
2592                    let tick_count = bar.tick_count.max(2);
2593                    let mut tick_data: Vec<(String, f32, f32)> = Vec::new(); // (text, total_w, height)
2594                    let mut max_tick_w = 0.0f32;
2595                    let mut tick_h = 0.0f32;
2596                    for i in 0..tick_count {
2597                        let t = i as f32 / (tick_count - 1) as f32;
2598                        let value = bar.scalar_min + t * (bar.scalar_max - bar.scalar_min);
2599                        let text = format!("{value:.2}");
2600                        let layout = self.resources.glyph_atlas.layout_text(
2601                            &text, tick_fs, bar.font, device,
2602                        );
2603                        max_tick_w = max_tick_w.max(layout.total_width);
2604                        tick_h = layout.height;
2605                        tick_data.push((text, layout.total_width, layout.height));
2606                    }
2607
2608                    // Vertical space reserved above the gradient strip.
2609                    // In vertical mode the top/bottom tick labels are centred on the strip
2610                    // endpoints, so they each overhang by tick_h/2. title_h must absorb the
2611                    // top overhang AND leave a gap so the title text does not touch the tick.
2612                    let half_tick = tick_h / 2.0;
2613                    let title_h = if bar.title.is_some() {
2614                        // title text height + small gap + top-tick overhang
2615                        title_fs + 4.0 + half_tick
2616                    } else {
2617                        // no title, but still need room for the top-tick overhang
2618                        half_tick
2619                    };
2620
2621                    // Pre-compute title width before bar_x/bar_y so the overhang can
2622                    // be used to push the strip inward and prevent clipping.
2623                    let title_w = if let Some(ref t) = bar.title {
2624                        self.resources.glyph_atlas.layout_text(t, title_fs, bar.font, device).total_width
2625                    } else {
2626                        0.0
2627                    };
2628
2629                    // How far title / tick labels spill beyond the strip on each side.
2630                    // Vertical: title centred on the narrow strip, ticks to the right.
2631                    //   left side: title overhang only.
2632                    //   right side: ticks dominate (strip_w + 4 + max_tick_w).
2633                    // Horizontal: both title and tick labels can overhang left/right equally.
2634                    let bg_pad = 4.0;
2635                    let (inset_left, inset_right) = if is_vertical {
2636                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
2637                        let right_extent = 4.0 + max_tick_w + bg_pad; // relative to strip right edge
2638                        (title_oh + bg_pad, right_extent)
2639                    } else {
2640                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
2641                        let tick_oh  = max_tick_w / 2.0;
2642                        let side = title_oh.max(tick_oh) + bg_pad;
2643                        (side, side)
2644                    };
2645
2646                    // How far content hangs below the strip bottom (used to keep the
2647                    // background box flush with margin_px on the bottom-anchored side).
2648                    // Vertical: bottom tick label is centred on the strip endpoint -> half_tick.
2649                    // Horizontal: tick labels sit fully below the strip -> 3 + tick_h.
2650                    let bottom_overhang = if is_vertical { half_tick } else { 3.0 + tick_h };
2651
2652                    // Top-left of the gradient strip.
2653                    // bg_pad is added/subtracted here so that the background box edge lands
2654                    // exactly at margin_px from the viewport edge on the anchored side.
2655                    //   Top anchor:    bg_y0 = bar_y - title_h - bg_pad  =>  set bar_y = margin_px + title_h + bg_pad
2656                    //   Bottom anchor: bg_y1 = bar_y + strip_h + bottom_overhang + bg_pad  =>  bar_y = vp_h - margin_px - strip_h - bottom_overhang - bg_pad
2657                    let (bar_x, bar_y) = match bar.anchor {
2658                        crate::renderer::types::ScalarBarAnchor::TopLeft => (
2659                            bar.margin_px + inset_left,
2660                            bar.margin_px + title_h + bg_pad,
2661                        ),
2662                        crate::renderer::types::ScalarBarAnchor::TopRight => (
2663                            vp_w - bar.margin_px - strip_w - inset_right,
2664                            bar.margin_px + title_h + bg_pad,
2665                        ),
2666                        crate::renderer::types::ScalarBarAnchor::BottomLeft => (
2667                            bar.margin_px + inset_left,
2668                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
2669                        ),
2670                        crate::renderer::types::ScalarBarAnchor::BottomRight => (
2671                            vp_w - bar.margin_px - strip_w - inset_right,
2672                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
2673                        ),
2674                    };
2675
2676                    // Background box: now that bar_x/bar_y are inset, the box stays on screen.
2677                    let (bg_x0, bg_y0, bg_x1, bg_y1) = if is_vertical {
2678                        let title_right = bar_x + (strip_w + title_w) / 2.0;
2679                        let ticks_right = bar_x + strip_w + 4.0 + max_tick_w;
2680                        (
2681                            bar_x - bg_pad - ((title_w - strip_w) / 2.0).max(0.0),
2682                            bar_y - title_h - bg_pad,
2683                            ticks_right.max(title_right) + bg_pad,
2684                            bar_y + strip_h + half_tick + bg_pad,
2685                        )
2686                    } else {
2687                        let title_overhang = ((title_w - strip_w) / 2.0).max(0.0);
2688                        let tick_overhang  = max_tick_w / 2.0;
2689                        let side_pad = title_overhang.max(tick_overhang);
2690                        let bottom = bar_y + strip_h + 3.0 + tick_h + bg_pad;
2691                        (
2692                            bar_x - bg_pad - side_pad,
2693                            bar_y - title_h - bg_pad,
2694                            bar_x + strip_w + bg_pad + side_pad,
2695                            bottom,
2696                        )
2697                    };
2698                    emit_rounded_quad(
2699                        &mut verts,
2700                        bg_x0, bg_y0, bg_x1, bg_y1,
2701                        3.0,
2702                        bar.background_color,
2703                        vp_w, vp_h,
2704                    );
2705
2706                    // Gradient strip: 64 solid quads sampled from the colormap LUT.
2707                    let steps: usize = 64;
2708                    for s in 0..steps {
2709                        let (qx0, qy0, qx1, qy1, t) = if is_vertical {
2710                            // Default: top = max (t=1). Reversed: top = min (t=0).
2711                            let t = if reversed {
2712                                s as f32 / (steps - 1) as f32
2713                            } else {
2714                                1.0 - s as f32 / (steps - 1) as f32
2715                            };
2716                            let step_h = strip_h / steps as f32;
2717                            let sy = bar_y + s as f32 * step_h;
2718                            (bar_x, sy, bar_x + strip_w, sy + step_h + 0.5, t)
2719                        } else {
2720                            // Default: left = min (t=0). Reversed: left = max (t=1).
2721                            let t = if reversed {
2722                                1.0 - s as f32 / (steps - 1) as f32
2723                            } else {
2724                                s as f32 / (steps - 1) as f32
2725                            };
2726                            let step_w = strip_w / steps as f32;
2727                            let sx = bar_x + s as f32 * step_w;
2728                            (sx, bar_y, sx + step_w + 0.5, bar_y + strip_h, t)
2729                        };
2730                        let lut_idx = (t * 255.0).clamp(0.0, 255.0) as usize;
2731                        let [r, g, b, a] = lut[lut_idx];
2732                        let color = [
2733                            r as f32 / 255.0,
2734                            g as f32 / 255.0,
2735                            b as f32 / 255.0,
2736                            a as f32 / 255.0,
2737                        ];
2738                        emit_solid_quad(&mut verts, qx0, qy0, qx1, qy1, color, vp_w, vp_h);
2739                    }
2740
2741                    // Tick labels.
2742                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, tick_fs);
2743                    for (i, (text, tw, th)) in tick_data.iter().enumerate() {
2744                        let t = i as f32 / (tick_count - 1) as f32;
2745                        let layout = self.resources.glyph_atlas.layout_text(
2746                            text, tick_fs, bar.font, device,
2747                        );
2748
2749                        let (lx, ly) = if is_vertical {
2750                            // Place text to the right of the strip, vertically centered
2751                            // on its tick position.
2752                            // Default: top=max -> progress = 1.0-t puts max at top.
2753                            // Reversed: top=min -> progress = t puts min at top.
2754                            let progress = if reversed { t } else { 1.0 - t };
2755                            let tick_y = bar_y + progress * strip_h;
2756                            (bar_x + strip_w + 4.0, tick_y - th * 0.5)
2757                        } else {
2758                            // Place text below the strip, horizontally centered on its tick.
2759                            // Default: left=min -> tick at t*strip_w.
2760                            // Reversed: left=max -> tick at (1-t)*strip_w.
2761                            let frac = if reversed { 1.0 - t } else { t };
2762                            let tick_x = bar_x + frac * strip_w;
2763                            (tick_x - tw * 0.5, bar_y + strip_h + 3.0)
2764                        };
2765                        let _ = (tw, th); // used above
2766
2767                        for gq in &layout.quads {
2768                            let gx = lx + gq.pos[0];
2769                            let gy = ly + ascent + gq.pos[1];
2770                            emit_textured_quad(
2771                                &mut verts,
2772                                gx, gy,
2773                                gx + gq.size[0], gy + gq.size[1],
2774                                gq.uv_min, gq.uv_max,
2775                                bar.label_color,
2776                                vp_w, vp_h,
2777                            );
2778                        }
2779                    }
2780
2781                    // Optional title above the gradient strip.
2782                    if let Some(ref title_text) = bar.title {
2783                        let layout = self.resources.glyph_atlas.layout_text(
2784                            title_text, title_fs, bar.font, device,
2785                        );
2786                        let title_ascent = self.resources.glyph_atlas.font_ascent(font_index, title_fs);
2787                        // Centre the title over the gradient strip.
2788                        let tx = bar_x + (strip_w - layout.total_width) * 0.5;
2789                        let ty = bar_y - title_h;
2790                        for gq in &layout.quads {
2791                            let gx = tx + gq.pos[0];
2792                            let gy = ty + title_ascent + gq.pos[1];
2793                            emit_textured_quad(
2794                                &mut verts,
2795                                gx, gy,
2796                                gx + gq.size[0], gy + gq.size[1],
2797                                gq.uv_min, gq.uv_max,
2798                                bar.label_color,
2799                                vp_w, vp_h,
2800                            );
2801                        }
2802                    }
2803                }
2804
2805                // Upload any newly rasterized glyphs (may overlap with label upload above).
2806                self.resources.glyph_atlas.upload_if_dirty(queue);
2807
2808                if !verts.is_empty() {
2809                    let vertex_buf =
2810                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2811                            label: Some("overlay_scalar_bar_vbuf"),
2812                            contents: bytemuck::cast_slice(&verts),
2813                            usage: wgpu::BufferUsages::VERTEX,
2814                        });
2815                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2816                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2817                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2818                        label: Some("overlay_scalar_bar_bg"),
2819                        layout: bgl,
2820                        entries: &[
2821                            wgpu::BindGroupEntry {
2822                                binding: 0,
2823                                resource: wgpu::BindingResource::TextureView(
2824                                    &self.resources.glyph_atlas.view,
2825                                ),
2826                            },
2827                            wgpu::BindGroupEntry {
2828                                binding: 1,
2829                                resource: wgpu::BindingResource::Sampler(sampler),
2830                            },
2831                        ],
2832                    });
2833                    self.scalar_bar_gpu_data = Some(crate::resources::LabelGpuData {
2834                        vertex_buf,
2835                        vertex_count: verts.len() as u32,
2836                        bind_group,
2837                    });
2838                }
2839            }
2840        }
2841
2842        // ---------------------------------------------------------------
2843        // Rulers
2844        // ---------------------------------------------------------------
2845        self.ruler_gpu_data = None;
2846        if !frame.overlays.rulers.is_empty() {
2847            self.resources.ensure_overlay_text_pipeline(device);
2848            let vp_w = frame.camera.viewport_size[0];
2849            let vp_h = frame.camera.viewport_size[1];
2850            if vp_w > 0.0 && vp_h > 0.0 {
2851                let view = &frame.camera.render_camera.view;
2852                let proj = &frame.camera.render_camera.projection;
2853
2854                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2855
2856                for ruler in &frame.overlays.rulers {
2857                    // Project both endpoints to NDC (returns None only if behind camera).
2858                    let start_ndc = project_to_ndc(ruler.start, view, proj);
2859                    let end_ndc   = project_to_ndc(ruler.end,   view, proj);
2860
2861                    // Cull entirely when either endpoint is behind the camera.
2862                    let (Some(sndc), Some(endc)) = (start_ndc, end_ndc) else { continue };
2863
2864                    // Clip the segment to the viewport NDC box [-1,1]^2.
2865                    // This keeps the line visible when only one end is off-screen sideways.
2866                    let Some((csndc, cendc)) = clip_line_ndc(sndc, endc) else { continue };
2867
2868                    let [sx, sy] = ndc_to_screen_px(csndc, vp_w, vp_h);
2869                    let [ex, ey] = ndc_to_screen_px(cendc, vp_w, vp_h);
2870
2871                    // Track which original endpoints are within the viewport (for end caps).
2872                    let start_on_screen = ndc_in_viewport(sndc);
2873                    let end_on_screen   = ndc_in_viewport(endc);
2874
2875                    // Main ruler line.
2876                    emit_line_quad(
2877                        &mut verts,
2878                        sx, sy, ex, ey,
2879                        ruler.line_width_px,
2880                        ruler.color,
2881                        vp_w, vp_h,
2882                    );
2883
2884                    // End caps only at endpoints that are actually on screen.
2885                    if ruler.end_caps {
2886                        let dx = ex - sx;
2887                        let dy = ey - sy;
2888                        let len = (dx * dx + dy * dy).sqrt().max(0.001);
2889                        let cap_half = 5.0;
2890                        let px = -dy / len * cap_half;
2891                        let py =  dx / len * cap_half;
2892
2893                        if start_on_screen {
2894                            emit_line_quad(
2895                                &mut verts,
2896                                sx - px, sy - py,
2897                                sx + px, sy + py,
2898                                ruler.line_width_px,
2899                                ruler.color,
2900                                vp_w, vp_h,
2901                            );
2902                        }
2903                        if end_on_screen {
2904                            emit_line_quad(
2905                                &mut verts,
2906                                ex - px, ey - py,
2907                                ex + px, ey + py,
2908                                ruler.line_width_px,
2909                                ruler.color,
2910                                vp_w, vp_h,
2911                            );
2912                        }
2913                    }
2914
2915                    // Distance label: always shows true 3D distance.
2916                    // Place it at the midpoint of the visible (clipped) segment.
2917                    let start_world = glam::Vec3::from(ruler.start);
2918                    let end_world = glam::Vec3::from(ruler.end);
2919                    let distance = (end_world - start_world).length();
2920                    let text = format_ruler_distance(distance, ruler.label_format.as_deref());
2921
2922                    let mid_x = (sx + ex) * 0.5;
2923                    let mid_y = (sy + ey) * 0.5;
2924
2925                    let layout = self.resources.glyph_atlas.layout_text(
2926                        &text,
2927                        ruler.font_size,
2928                        ruler.font,
2929                        device,
2930                    );
2931                    let font_index = ruler.font.map_or(0, |h| h.0);
2932                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, ruler.font_size);
2933
2934                    // Center the label above the midpoint with a small gap.
2935                    let lx = mid_x - layout.total_width * 0.5;
2936                    let ly = mid_y - layout.height - 6.0;
2937
2938                    // Semi-transparent background box.
2939                    let pad = 3.0;
2940                    emit_solid_quad(
2941                        &mut verts,
2942                        lx - pad, ly - pad,
2943                        lx + layout.total_width + pad, ly + layout.height + pad,
2944                        [0.0, 0.0, 0.0, 0.55],
2945                        vp_w, vp_h,
2946                    );
2947
2948                    // Glyph quads.
2949                    for gq in &layout.quads {
2950                        let gx = lx + gq.pos[0];
2951                        let gy = ly + ascent + gq.pos[1];
2952                        emit_textured_quad(
2953                            &mut verts,
2954                            gx, gy,
2955                            gx + gq.size[0], gy + gq.size[1],
2956                            gq.uv_min, gq.uv_max,
2957                            ruler.label_color,
2958                            vp_w, vp_h,
2959                        );
2960                    }
2961                }
2962
2963                // Upload any newly rasterized glyphs.
2964                self.resources.glyph_atlas.upload_if_dirty(queue);
2965
2966                if !verts.is_empty() {
2967                    let vertex_buf =
2968                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2969                            label: Some("overlay_ruler_vbuf"),
2970                            contents: bytemuck::cast_slice(&verts),
2971                            usage: wgpu::BufferUsages::VERTEX,
2972                        });
2973                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2974                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2975                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2976                        label: Some("overlay_ruler_bg"),
2977                        layout: bgl,
2978                        entries: &[
2979                            wgpu::BindGroupEntry {
2980                                binding: 0,
2981                                resource: wgpu::BindingResource::TextureView(
2982                                    &self.resources.glyph_atlas.view,
2983                                ),
2984                            },
2985                            wgpu::BindGroupEntry {
2986                                binding: 1,
2987                                resource: wgpu::BindingResource::Sampler(sampler),
2988                            },
2989                        ],
2990                    });
2991                    self.ruler_gpu_data = Some(crate::resources::LabelGpuData {
2992                        vertex_buf,
2993                        vertex_count: verts.len() as u32,
2994                        bind_group,
2995                    });
2996                }
2997            }
2998        }
2999
3000        // ---------------------------------------------------------------
3001        // Loading bars
3002        // ---------------------------------------------------------------
3003        self.loading_bar_gpu_data = None;
3004        if !frame.overlays.loading_bars.is_empty() {
3005            self.resources.ensure_overlay_text_pipeline(device);
3006            let vp_w = frame.camera.viewport_size[0];
3007            let vp_h = frame.camera.viewport_size[1];
3008            if vp_w > 0.0 && vp_h > 0.0 {
3009                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
3010
3011                for bar in &frame.overlays.loading_bars {
3012                    // Bar top-left corner based on anchor.
3013                    let bar_x = vp_w * 0.5 - bar.width_px * 0.5;
3014                    let bar_y = match bar.anchor {
3015                        crate::renderer::types::LoadingBarAnchor::TopCenter => bar.margin_px,
3016                        crate::renderer::types::LoadingBarAnchor::Center => {
3017                            vp_h * 0.5 - bar.height_px * 0.5
3018                        }
3019                        crate::renderer::types::LoadingBarAnchor::BottomCenter => {
3020                            vp_h - bar.margin_px - bar.height_px
3021                        }
3022                    };
3023
3024                    // Label above (TopCenter: below) the bar.
3025                    if let Some(ref text) = bar.label {
3026                        let layout = self.resources.glyph_atlas.layout_text(
3027                            text,
3028                            bar.font_size,
3029                            None,
3030                            device,
3031                        );
3032                        let ascent =
3033                            self.resources.glyph_atlas.font_ascent(0, bar.font_size);
3034                        let label_gap = 5.0;
3035                        let lx = bar_x + bar.width_px * 0.5 - layout.total_width * 0.5;
3036                        let ly = match bar.anchor {
3037                            crate::renderer::types::LoadingBarAnchor::TopCenter => {
3038                                bar_y + bar.height_px + label_gap
3039                            }
3040                            _ => bar_y - layout.height - label_gap,
3041                        };
3042                        for gq in &layout.quads {
3043                            let gx = lx + gq.pos[0];
3044                            let gy = ly + ascent + gq.pos[1];
3045                            emit_textured_quad(
3046                                &mut verts,
3047                                gx, gy,
3048                                gx + gq.size[0], gy + gq.size[1],
3049                                gq.uv_min, gq.uv_max,
3050                                bar.label_color,
3051                                vp_w, vp_h,
3052                            );
3053                        }
3054                    }
3055
3056                    // Background rectangle.
3057                    emit_rounded_quad(
3058                        &mut verts,
3059                        bar_x, bar_y,
3060                        bar_x + bar.width_px, bar_y + bar.height_px,
3061                        bar.corner_radius,
3062                        bar.background_color,
3063                        vp_w, vp_h,
3064                    );
3065
3066                    // Fill rectangle clipped to progress fraction.
3067                    let fill_w = bar.width_px * bar.progress.clamp(0.0, 1.0);
3068                    if fill_w > 0.5 {
3069                        emit_rounded_quad(
3070                            &mut verts,
3071                            bar_x, bar_y,
3072                            bar_x + fill_w, bar_y + bar.height_px,
3073                            bar.corner_radius,
3074                            bar.fill_color,
3075                            vp_w, vp_h,
3076                        );
3077                    }
3078                }
3079
3080                self.resources.glyph_atlas.upload_if_dirty(queue);
3081
3082                if !verts.is_empty() {
3083                    let vertex_buf =
3084                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3085                            label: Some("loading_bar_vbuf"),
3086                            contents: bytemuck::cast_slice(&verts),
3087                            usage: wgpu::BufferUsages::VERTEX,
3088                        });
3089                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3090                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3091                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3092                        label: Some("loading_bar_bg"),
3093                        layout: bgl,
3094                        entries: &[
3095                            wgpu::BindGroupEntry {
3096                                binding: 0,
3097                                resource: wgpu::BindingResource::TextureView(
3098                                    &self.resources.glyph_atlas.view,
3099                                ),
3100                            },
3101                            wgpu::BindGroupEntry {
3102                                binding: 1,
3103                                resource: wgpu::BindingResource::Sampler(sampler),
3104                            },
3105                        ],
3106                    });
3107                    self.loading_bar_gpu_data = Some(crate::resources::LabelGpuData {
3108                        vertex_buf,
3109                        vertex_count: verts.len() as u32,
3110                        bind_group,
3111                    });
3112                }
3113            }
3114        }
3115    }
3116
3117    /// Upload per-frame data to GPU buffers and render the shadow pass.
3118    /// Call before `paint()`.
3119    ///
3120    /// Returns [`crate::FrameStats`] with per-frame timing and upload metrics.
3121    pub fn prepare(
3122        &mut self,
3123        device: &wgpu::Device,
3124        queue: &wgpu::Queue,
3125        frame: &FrameData,
3126    ) -> crate::renderer::stats::FrameStats {
3127        let prepare_start = std::time::Instant::now();
3128
3129        // Phase 4 : read back GPU timestamps from the previous frame, if available.
3130        // By the time prepare() is called, the previous frame's queue.submit() has
3131        // already happened, so it is safe to initiate the map here.
3132        if self.ts_needs_readback {
3133            if let Some(ref stg_buf) = self.ts_staging_buf {
3134                let (tx, rx) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3135                stg_buf.slice(..).map_async(wgpu::MapMode::Read, move |r| {
3136                    let _ = tx.send(r);
3137                });
3138                // Non-blocking poll: flush any completed callbacks. GPU work from the
3139                // previous frame is almost certainly done by the time CPU reaches here.
3140                device
3141                    .poll(wgpu::PollType::Wait {
3142                        submission_index: None,
3143                        timeout: Some(std::time::Duration::from_millis(100)),
3144                    })
3145                    .ok();
3146                if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
3147                    let data = stg_buf.slice(..).get_mapped_range();
3148                    let t0 = u64::from_le_bytes(data[0..8].try_into().unwrap());
3149                    let t1 = u64::from_le_bytes(data[8..16].try_into().unwrap());
3150                    drop(data);
3151                    // ts_period is nanoseconds/tick; convert delta to milliseconds.
3152                    let gpu_ms = t1.saturating_sub(t0) as f32 * self.ts_period / 1_000_000.0;
3153                    self.last_stats.gpu_frame_ms = Some(gpu_ms);
3154                }
3155                stg_buf.unmap();
3156            }
3157            self.ts_needs_readback = false;
3158        }
3159
3160        // Read back GPU-visible instance count from the previous frame's indirect args copy.
3161        // The cull pass from the previous frame has already been submitted and is almost
3162        // certainly done by the time prepare() is called; a short poll is enough.
3163        if self.indirect_readback_pending {
3164            if let Some(ref stg_buf) = self.indirect_readback_buf {
3165                let bytes = self.indirect_readback_batch_count as u64 * 20;
3166                if bytes > 0 {
3167                    let (tx, rx) =
3168                        std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3169                    stg_buf.slice(..bytes).map_async(wgpu::MapMode::Read, move |r| {
3170                        let _ = tx.send(r);
3171                    });
3172                    device
3173                        .poll(wgpu::PollType::Wait {
3174                            submission_index: None,
3175                            timeout: Some(std::time::Duration::from_millis(100)),
3176                        })
3177                        .ok();
3178                    if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
3179                        let data = stg_buf.slice(..bytes).get_mapped_range();
3180                        let mut visible: u32 = 0;
3181                        for i in 0..self.indirect_readback_batch_count as usize {
3182                            // DrawIndexedIndirect layout: [index_count, instance_count, first_index, base_vertex, first_instance]
3183                            // instance_count is at byte offset 4 within each 20-byte entry.
3184                            let off = i * 20 + 4;
3185                            let n = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
3186                            visible = visible.saturating_add(n);
3187                        }
3188                        drop(data);
3189                        self.last_stats.gpu_visible_instances = Some(visible);
3190                    }
3191                    stg_buf.unmap();
3192                }
3193            }
3194            self.indirect_readback_pending = false;
3195        }
3196
3197        // Wall-clock duration since the previous prepare() call approximates the frame interval.
3198        let total_frame_ms = self
3199            .last_prepare_instant
3200            .map(|t| t.elapsed().as_secs_f32() * 1000.0)
3201            .unwrap_or(0.0);
3202
3203        // Snapshot geometry upload bytes accumulated since the last frame, then reset.
3204        let upload_bytes = self.resources.frame_upload_bytes;
3205        self.resources.frame_upload_bytes = 0;
3206
3207        // Resolve effective scale bounds and degradation flags.
3208        // When a preset is set it overrides the individual fields; the individual
3209        // fields are preserved so they restore when switching back to None.
3210        let policy = self.performance_policy;
3211        let (eff_min_scale, eff_max_scale, eff_allow_shadows, eff_allow_volumes, eff_allow_effects) =
3212            match policy.preset {
3213                Some(crate::renderer::stats::QualityPreset::High) => {
3214                    (1.0_f32, 1.0_f32, false, false, false)
3215                }
3216                Some(crate::renderer::stats::QualityPreset::Medium) => {
3217                    (0.75_f32, 1.0_f32, true, false, true)
3218                }
3219                Some(crate::renderer::stats::QualityPreset::Low) => {
3220                    (0.5_f32, 0.75_f32, true, true, true)
3221                }
3222                None => (
3223                    policy.min_render_scale,
3224                    policy.max_render_scale,
3225                    policy.allow_shadow_reduction,
3226                    policy.allow_volume_quality_reduction,
3227                    policy.allow_effect_throttling,
3228                ),
3229            };
3230
3231        // Capture mode: force max render scale and suppress all degradation.
3232        // The adaptation controller is paused for the duration of the frame.
3233        let in_capture = self.runtime_mode == crate::renderer::stats::RuntimeMode::Capture;
3234        if in_capture {
3235            self.current_render_scale = eff_max_scale;
3236        }
3237
3238        // HDR path detection: post_process.enabled means render()/render_viewport()
3239        // will be called. Dynamic resolution is not implemented for the HDR path
3240        // (post-tonemap passes pair output_view with hdr_depth_view and require
3241        // matching dimensions). Suppress the controller and pin render_scale to 1.0
3242        // so FrameStats does not report a misleading value.
3243        let hdr_active = frame.effects.post_process.enabled;
3244
3245        // When a preset is active, clamp current_render_scale to the preset's bounds
3246        // immediately, without requiring allow_dynamic_resolution. This ensures the
3247        // preset has a visible effect even when the adaptation controller is off.
3248        // The controller can still adjust within these bounds when enabled.
3249        if !in_capture && !hdr_active && policy.preset.is_some() {
3250            self.current_render_scale =
3251                self.current_render_scale.clamp(eff_min_scale, eff_max_scale);
3252        }
3253
3254        // Tiered degradation ladder.
3255        // Order: render scale -> shadows -> volumes -> effects.
3256        // The tier advances one step per over-budget frame once render scale has
3257        // reached its minimum (nothing more the controller can reduce).
3258        // The tier retreats one step per frame that is comfortably under budget,
3259        // reversing the ladder in the same order (effects first).
3260        // Capture mode resets the tier; HDR path leaves it unchanged but flags
3261        // are suppressed below regardless.
3262        let missed_prev = self.last_stats.missed_budget;
3263        let under_prev = !self.last_stats.missed_budget
3264            && policy
3265                .target_fps
3266                .map(|fps| {
3267                    let budget = 1000.0 / fps;
3268                    let sig = self
3269                        .last_stats
3270                        .gpu_frame_ms
3271                        .unwrap_or(self.last_stats.total_frame_ms);
3272                    sig < budget * 0.8
3273                })
3274                .unwrap_or(true);
3275        if in_capture {
3276            self.degradation_tier = 0;
3277        } else if !hdr_active {
3278            let at_min = !policy.allow_dynamic_resolution
3279                || self.current_render_scale <= eff_min_scale + 0.001;
3280            if missed_prev && at_min {
3281                self.degradation_tier = (self.degradation_tier + 1).min(3);
3282            } else if under_prev {
3283                self.degradation_tier = self.degradation_tier.saturating_sub(1);
3284            }
3285        }
3286
3287        // Derive per-pass flags from the current tier and effective policy.
3288        // All flags are suppressed in Capture mode regardless of tier.
3289        self.degradation_shadows_skipped =
3290            !in_capture && self.degradation_tier >= 1 && eff_allow_shadows;
3291        self.degradation_volume_quality_reduced =
3292            !in_capture && self.degradation_tier >= 2 && eff_allow_volumes;
3293        self.degradation_effects_throttled =
3294            !in_capture && self.degradation_tier >= 3 && eff_allow_effects;
3295
3296        let (scene_fx, viewport_fx) = frame.effects.split();
3297        self.prepare_scene_internal(device, queue, frame, &scene_fx);
3298        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
3299
3300        let cpu_prepare_ms = prepare_start.elapsed().as_secs_f32() * 1000.0;
3301
3302        let budget_ms = policy.target_fps.map(|fps| 1000.0 / fps);
3303
3304        // Controller signal: prefer gpu_frame_ms (excludes vsync wait, one-frame lag is
3305        // acceptable). Fall back to total_frame_ms when GPU timestamps are unavailable:
3306        // it reflects wall-clock frame duration and correctly fires over-budget at low
3307        // frame rates. cpu_prepare_ms is not used as a fallback because it only measures
3308        // CPU-side work and is low even when the GPU or driver is the bottleneck.
3309        let controller_ms = self
3310            .last_stats
3311            .gpu_frame_ms
3312            .unwrap_or(total_frame_ms);
3313
3314        // Capture mode always reports missed_budget = false; degradation is suppressed.
3315        let missed_budget = !in_capture
3316            && budget_ms.map(|b| controller_ms > b).unwrap_or(false);
3317
3318        // Adaptation controller: adjust render scale within effective bounds when enabled.
3319        // Uses controller_ms from the previous frame (gpu_frame_ms when available,
3320        // otherwise total_frame_ms). Paused in Capture mode and when the HDR path is
3321        // active (see hdr_active above).
3322        if policy.allow_dynamic_resolution && !in_capture && !hdr_active {
3323            if let Some(budget) = budget_ms {
3324                if controller_ms > budget {
3325                    // Over budget: step down quickly.
3326                    self.current_render_scale =
3327                        (self.current_render_scale - 0.1).max(eff_min_scale);
3328                } else if controller_ms < budget * 0.8 {
3329                    // Comfortably under budget: recover slowly to avoid oscillation.
3330                    self.current_render_scale =
3331                        (self.current_render_scale + 0.05).min(eff_max_scale);
3332                }
3333            }
3334        }
3335
3336        self.last_prepare_instant = Some(prepare_start);
3337        self.frame_counter = self.frame_counter.wrapping_add(1);
3338
3339        // On the HDR path the render_scale has no effect on output; report 1.0
3340        // so consumers are not misled by a value that is changing but doing nothing.
3341        let reported_render_scale = if hdr_active { 1.0 } else { self.current_render_scale };
3342
3343        let stats = crate::renderer::stats::FrameStats {
3344            cpu_prepare_ms,
3345            // gpu_frame_ms is updated by the timestamp readback above when available;
3346            // propagate the most recent value from last_stats.
3347            gpu_frame_ms: self.last_stats.gpu_frame_ms,
3348            total_frame_ms,
3349            render_scale: reported_render_scale,
3350            missed_budget,
3351            upload_bytes,
3352            shadows_skipped: self.degradation_shadows_skipped,
3353            volume_quality_reduced: self.degradation_volume_quality_reduced,
3354            // effects_throttled is set by the render path; carry forward here so
3355            // prepare()-only callers still see the previous frame's value until
3356            // paint_to()/render() updates it.
3357            effects_throttled: self.degradation_effects_throttled,
3358            ..self.last_stats
3359        };
3360        self.last_stats = stats;
3361        stats
3362    }
3363}
3364
3365// ---------------------------------------------------------------------------
3366// Clip boundary wireframe helpers (used by prepare_viewport_internal)
3367// ---------------------------------------------------------------------------
3368
3369/// Wireframe outline for a clip box (12 edges as 2-point polyline strips).
3370fn clip_box_outline(
3371    center: [f32; 3],
3372    half: [f32; 3],
3373    orientation: [[f32; 3]; 3],
3374    color: [f32; 4],
3375) -> PolylineItem {
3376    let ax = glam::Vec3::from(orientation[0]) * half[0];
3377    let ay = glam::Vec3::from(orientation[1]) * half[1];
3378    let az = glam::Vec3::from(orientation[2]) * half[2];
3379    let c = glam::Vec3::from(center);
3380
3381    let corners = [
3382        c - ax - ay - az,
3383        c + ax - ay - az,
3384        c + ax + ay - az,
3385        c - ax + ay - az,
3386        c - ax - ay + az,
3387        c + ax - ay + az,
3388        c + ax + ay + az,
3389        c - ax + ay + az,
3390    ];
3391    let edges: [(usize, usize); 12] = [
3392        (0, 1),
3393        (1, 2),
3394        (2, 3),
3395        (3, 0), // bottom face
3396        (4, 5),
3397        (5, 6),
3398        (6, 7),
3399        (7, 4), // top face
3400        (0, 4),
3401        (1, 5),
3402        (2, 6),
3403        (3, 7), // verticals
3404    ];
3405
3406    let mut positions = Vec::with_capacity(24);
3407    let mut strip_lengths = Vec::with_capacity(12);
3408    for (a, b) in edges {
3409        positions.push(corners[a].to_array());
3410        positions.push(corners[b].to_array());
3411        strip_lengths.push(2u32);
3412    }
3413
3414    let mut item = PolylineItem::default();
3415    item.positions = positions;
3416    item.strip_lengths = strip_lengths;
3417    item.default_color = color;
3418    item.line_width = 2.0;
3419    item
3420}
3421
3422/// Wireframe outline for a clip sphere (three great circles).
3423fn clip_sphere_outline(center: [f32; 3], radius: f32, color: [f32; 4]) -> PolylineItem {
3424    let c = glam::Vec3::from(center);
3425    let segs = 64usize;
3426    let mut positions = Vec::with_capacity((segs + 1) * 3);
3427    let mut strip_lengths = Vec::with_capacity(3);
3428
3429    for axis in 0..3usize {
3430        let start = positions.len();
3431        for i in 0..=segs {
3432            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
3433            let (s, cs) = t.sin_cos();
3434            let p = c + match axis {
3435                0 => glam::Vec3::new(cs * radius, s * radius, 0.0),
3436                1 => glam::Vec3::new(cs * radius, 0.0, s * radius),
3437                _ => glam::Vec3::new(0.0, cs * radius, s * radius),
3438            };
3439            positions.push(p.to_array());
3440        }
3441        strip_lengths.push((positions.len() - start) as u32);
3442    }
3443
3444    let mut item = PolylineItem::default();
3445    item.positions = positions;
3446    item.strip_lengths = strip_lengths;
3447    item.default_color = color;
3448    item.line_width = 2.0;
3449    item
3450}
3451
3452// ---------------------------------------------------------------------------
3453// Overlay label helpers
3454// ---------------------------------------------------------------------------
3455
3456/// Project a world-space position to NDC.
3457/// Returns `None` only if the point is behind the camera (`clip.w <= 0`).
3458/// Does NOT reject points outside the [-1,1] viewport box.
3459fn project_to_ndc(
3460    pos: [f32; 3],
3461    view: &glam::Mat4,
3462    proj: &glam::Mat4,
3463) -> Option<[f32; 2]> {
3464    let clip = *proj * *view * glam::Vec3::from(pos).extend(1.0);
3465    if clip.w <= 0.0 { return None; }
3466    Some([clip.x / clip.w, clip.y / clip.w])
3467}
3468
3469/// Convert NDC coordinates to screen pixels (top-left origin).
3470fn ndc_to_screen_px(ndc: [f32; 2], vp_w: f32, vp_h: f32) -> [f32; 2] {
3471    [
3472        (ndc[0] * 0.5 + 0.5) * vp_w,
3473        (1.0 - (ndc[1] * 0.5 + 0.5)) * vp_h,
3474    ]
3475}
3476
3477/// Returns true when the NDC point lies within the viewport square.
3478fn ndc_in_viewport(ndc: [f32; 2]) -> bool {
3479    ndc[0] >= -1.0 && ndc[0] <= 1.0 && ndc[1] >= -1.0 && ndc[1] <= 1.0
3480}
3481
3482/// Clip a line segment [a, b] in NDC to the [-1,1]^2 viewport box
3483/// using the Liang-Barsky algorithm.
3484/// Returns the clipped endpoints, or `None` if the segment is entirely outside.
3485fn clip_line_ndc(a: [f32; 2], b: [f32; 2]) -> Option<([f32; 2], [f32; 2])> {
3486    let dx = b[0] - a[0];
3487    let dy = b[1] - a[1];
3488    let mut t0 = 0.0f32;
3489    let mut t1 = 1.0f32;
3490
3491    // (p, q) pairs for left, right, bottom, top boundaries.
3492    for (p, q) in [
3493        (-dx, a[0] + 1.0),
3494        ( dx, 1.0 - a[0]),
3495        (-dy, a[1] + 1.0),
3496        ( dy, 1.0 - a[1]),
3497    ] {
3498        if p == 0.0 {
3499            if q < 0.0 { return None; }
3500        } else {
3501            let r = q / p;
3502            if p < 0.0 { t0 = t0.max(r); } else { t1 = t1.min(r); }
3503        }
3504    }
3505
3506    if t0 > t1 { return None; }
3507    Some((
3508        [a[0] + t0 * dx, a[1] + t0 * dy],
3509        [a[0] + t1 * dx, a[1] + t1 * dy],
3510    ))
3511}
3512
3513/// Project a world-space position to screen pixels (top-left origin).
3514/// Returns `None` if behind the camera or outside the frustum.
3515fn project_to_screen(
3516    pos: [f32; 3],
3517    view: &glam::Mat4,
3518    proj: &glam::Mat4,
3519    vp_w: f32,
3520    vp_h: f32,
3521) -> Option<[f32; 2]> {
3522    let p = glam::Vec3::from(pos);
3523    let clip = *proj * *view * p.extend(1.0);
3524    if clip.w <= 0.0 {
3525        return None;
3526    }
3527    let ndc_x = clip.x / clip.w;
3528    let ndc_y = clip.y / clip.w;
3529    if ndc_x < -1.0 || ndc_x > 1.0 || ndc_y < -1.0 || ndc_y > 1.0 {
3530        return None;
3531    }
3532    let x = (ndc_x * 0.5 + 0.5) * vp_w;
3533    let y = (1.0 - (ndc_y * 0.5 + 0.5)) * vp_h;
3534    Some([x, y])
3535}
3536
3537/// Convert screen pixel coordinates to NDC.
3538#[inline]
3539fn px_to_ndc(px_x: f32, px_y: f32, vp_w: f32, vp_h: f32) -> [f32; 2] {
3540    [
3541        px_x / vp_w * 2.0 - 1.0,
3542        1.0 - px_y / vp_h * 2.0,
3543    ]
3544}
3545
3546/// Emit a solid-colour quad (6 vertices) in screen pixel coordinates.
3547fn emit_solid_quad(
3548    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3549    x0: f32, y0: f32,
3550    x1: f32, y1: f32,
3551    color: [f32; 4],
3552    vp_w: f32, vp_h: f32,
3553) {
3554    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
3555    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
3556    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
3557    let br = px_to_ndc(x1, y1, vp_w, vp_h);
3558    let uv = [0.0, 0.0];
3559    let tex = 0.0;
3560    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
3561        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3562    };
3563    verts.extend_from_slice(&[v(tl), v(bl), v(tr), v(tr), v(bl), v(br)]);
3564}
3565
3566/// Emit a textured quad (6 vertices) for a glyph in screen pixel coordinates.
3567fn emit_textured_quad(
3568    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3569    x0: f32, y0: f32,
3570    x1: f32, y1: f32,
3571    uv_min: [f32; 2],
3572    uv_max: [f32; 2],
3573    color: [f32; 4],
3574    vp_w: f32, vp_h: f32,
3575) {
3576    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
3577    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
3578    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
3579    let br = px_to_ndc(x1, y1, vp_w, vp_h);
3580    let tex = 1.0;
3581    let v = |pos: [f32; 2], uv: [f32; 2]| crate::resources::OverlayTextVertex {
3582        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3583    };
3584    // UV layout: top-left = uv_min, bottom-right = uv_max.
3585    verts.extend_from_slice(&[
3586        v(tl, uv_min),
3587        v(bl, [uv_min[0], uv_max[1]]),
3588        v(tr, [uv_max[0], uv_min[1]]),
3589        v(tr, [uv_max[0], uv_min[1]]),
3590        v(bl, [uv_min[0], uv_max[1]]),
3591        v(br, uv_max),
3592    ]);
3593}
3594
3595/// Emit a thin screen-space line as a quad (6 vertices).
3596fn emit_line_quad(
3597    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3598    x0: f32, y0: f32,
3599    x1: f32, y1: f32,
3600    thickness: f32,
3601    color: [f32; 4],
3602    vp_w: f32, vp_h: f32,
3603) {
3604    let dx = x1 - x0;
3605    let dy = y1 - y0;
3606    let len = (dx * dx + dy * dy).sqrt();
3607    if len < 0.001 {
3608        return;
3609    }
3610    let half = thickness * 0.5;
3611    let nx = -dy / len * half;
3612    let ny = dx / len * half;
3613
3614    let p0 = px_to_ndc(x0 + nx, y0 + ny, vp_w, vp_h);
3615    let p1 = px_to_ndc(x0 - nx, y0 - ny, vp_w, vp_h);
3616    let p2 = px_to_ndc(x1 + nx, y1 + ny, vp_w, vp_h);
3617    let p3 = px_to_ndc(x1 - nx, y1 - ny, vp_w, vp_h);
3618    let uv = [0.0, 0.0];
3619    let tex = 0.0;
3620    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
3621        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3622    };
3623    verts.extend_from_slice(&[v(p0), v(p1), v(p2), v(p2), v(p1), v(p3)]);
3624}
3625
3626/// Apply an opacity multiplier to a colour's alpha channel.
3627#[inline]
3628fn apply_opacity(color: [f32; 4], opacity: f32) -> [f32; 4] {
3629    [color[0], color[1], color[2], color[3] * opacity]
3630}
3631
3632/// Emit a rounded rectangle as solid quads: one center rect + four edge rects +
3633/// four corner fans.  This is a CPU tessellation approach that avoids shader
3634/// changes.
3635fn emit_rounded_quad(
3636    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3637    x0: f32, y0: f32,
3638    x1: f32, y1: f32,
3639    radius: f32,
3640    color: [f32; 4],
3641    vp_w: f32, vp_h: f32,
3642) {
3643    let w = x1 - x0;
3644    let h = y1 - y0;
3645    let r = radius.min(w * 0.5).min(h * 0.5).max(0.0);
3646
3647    if r < 0.5 {
3648        emit_solid_quad(verts, x0, y0, x1, y1, color, vp_w, vp_h);
3649        return;
3650    }
3651
3652    // Center cross (two rects that cover everything except the corners).
3653    // Horizontal bar (full width, inset top/bottom by r).
3654    emit_solid_quad(verts, x0, y0 + r, x1, y1 - r, color, vp_w, vp_h);
3655    // Top bar (inset left/right by r, top edge).
3656    emit_solid_quad(verts, x0 + r, y0, x1 - r, y0 + r, color, vp_w, vp_h);
3657    // Bottom bar.
3658    emit_solid_quad(verts, x0 + r, y1 - r, x1 - r, y1, color, vp_w, vp_h);
3659
3660    // Four corner fans.
3661    let corners = [
3662        (x0 + r, y0 + r, std::f32::consts::PI, std::f32::consts::FRAC_PI_2 * 3.0),       // top-left
3663        (x1 - r, y0 + r, std::f32::consts::FRAC_PI_2 * 3.0, std::f32::consts::TAU),      // top-right
3664        (x1 - r, y1 - r, 0.0, std::f32::consts::FRAC_PI_2),                               // bottom-right
3665        (x0 + r, y1 - r, std::f32::consts::FRAC_PI_2, std::f32::consts::PI),              // bottom-left
3666    ];
3667    let segments = 6;
3668    let uv = [0.0, 0.0];
3669    let tex = 0.0;
3670    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
3671        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3672    };
3673    for (cx, cy, start, end) in corners {
3674        let center = px_to_ndc(cx, cy, vp_w, vp_h);
3675        for i in 0..segments {
3676            let a0 = start + (end - start) * i as f32 / segments as f32;
3677            let a1 = start + (end - start) * (i + 1) as f32 / segments as f32;
3678            let p0 = px_to_ndc(cx + a0.cos() * r, cy + a0.sin() * r, vp_w, vp_h);
3679            let p1 = px_to_ndc(cx + a1.cos() * r, cy + a1.sin() * r, vp_w, vp_h);
3680            verts.extend_from_slice(&[v(center), v(p0), v(p1)]);
3681        }
3682    }
3683}
3684
3685// ---------------------------------------------------------------------------
3686// Ruler label formatting
3687// ---------------------------------------------------------------------------
3688
3689/// Format a distance value using a caller-supplied format pattern.
3690///
3691/// The pattern may contain one `{...}` placeholder with an optional precision
3692/// specifier, e.g. `"{:.3}"` or `"{:.2} m"`.  Anything outside the braces is
3693/// treated as a literal prefix / suffix.  Unrecognised patterns fall back to
3694/// three decimal places.
3695fn format_ruler_distance(distance: f32, fmt: Option<&str>) -> String {
3696    let pattern = fmt.unwrap_or("{:.3}");
3697    // Find the first `{...}` block.
3698    if let Some(open) = pattern.find('{') {
3699        if let Some(close_rel) = pattern[open..].find('}') {
3700            let close = open + close_rel;
3701            let spec = &pattern[open + 1..close]; // e.g. ":.3" or ""
3702            let prefix = &pattern[..open];
3703            let suffix = &pattern[close + 1..];
3704            let formatted = if let Some(prec_str) = spec.strip_prefix(":.") {
3705                // Strip trailing 'f' for patterns like "{:.3f}".
3706                let prec_str = prec_str.trim_end_matches('f');
3707                if let Ok(prec) = prec_str.parse::<usize>() {
3708                    format!("{distance:.prec$}")
3709                } else {
3710                    format!("{distance:.3}")
3711                }
3712            } else if spec.is_empty() || spec == ":" {
3713                format!("{distance}")
3714            } else {
3715                format!("{distance:.3}")
3716            };
3717            return format!("{prefix}{formatted}{suffix}");
3718        }
3719    }
3720    format!("{distance:.3}")
3721}