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