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        // Collect per-item uniforms when wireframe mode is on so we can give each
398        // visible item its own bind group (the mesh's shared object_uniform_buf gets
399        // overwritten when multiple items reference the same MeshId).
400        let mut wireframe_uniforms: Vec<ObjectUniform> = Vec::new();
401        let collect_wf_uniforms = frame.viewport.wireframe_mode;
402        if !self.use_instancing
403            || frame.viewport.wireframe_mode
404            || has_scalar_items
405            || has_two_sided_items
406            || has_matcap_items
407            || has_param_vis_items
408            || has_wireframe_items
409        {
410            for item in scene_items {
411                // When instancing is active, skip items that will be rendered
412                // via the instanced path. They don't need per-object uniform
413                // writes; writing them anyway causes O(n) write_buffer calls
414                // for the whole scene whenever any single item is two-sided.
415                if self.use_instancing
416                    && !frame.viewport.wireframe_mode
417                    && item.active_attribute.is_none()
418                    && !item.material.is_two_sided()
419                    && item.material.matcap_id.is_none()
420                    && item.material.param_vis.is_none()
421                    && !item.render_as_wireframe
422                    && item.warp_attribute.is_none()
423                {
424                    continue;
425                }
426
427                if resources
428                    .mesh_store
429                    .get(item.mesh_id)
430                    .is_none()
431                {
432                    tracing::warn!(
433                        mesh_index = item.mesh_id.index(),
434                        "scene item mesh_index invalid, skipping"
435                    );
436                    continue;
437                };
438                let m = &item.material;
439                // Compute scalar attribute range.
440                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
441                    let range = item
442                        .scalar_range
443                        .or_else(|| {
444                            resources
445                                .mesh_store
446                                .get(item.mesh_id)
447                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
448                        })
449                        .unwrap_or((0.0, 1.0));
450                    (1u32, range.0, range.1)
451                } else {
452                    (0u32, 0.0, 1.0)
453                };
454                let obj_uniform = ObjectUniform {
455                    model: item.model,
456                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
457                    selected: if item.selected { 1 } else { 0 },
458                    wireframe: if frame.viewport.wireframe_mode || item.render_as_wireframe { 1 } else { 0 },
459                    ambient: m.ambient,
460                    diffuse: m.diffuse,
461                    specular: m.specular,
462                    shininess: m.shininess,
463                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
464                    use_pbr: if m.use_pbr { 1 } else { 0 },
465                    metallic: m.metallic,
466                    roughness: m.roughness,
467                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
468                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
469                    has_attribute: has_attr,
470                    scalar_min: s_min,
471                    scalar_max: s_max,
472                    _pad_scalar: 0,
473                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
474                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
475                    use_matcap: if m.matcap_id.is_some() { 1 } else { 0 },
476                    matcap_blendable: m.matcap_id.map_or(0, |id| if id.blendable { 1 } else { 0 }),
477                    unlit: if m.unlit { 1 } else { 0 },
478                    use_face_color: u32::from(item.active_attribute.as_ref().map_or(false, |a| {
479                        a.kind == crate::resources::AttributeKind::FaceColor
480                    })),
481                    uv_vis_mode: m.param_vis.map_or(0, |pv| pv.mode as u32),
482                    uv_vis_scale: m.param_vis.map_or(8.0, |pv| pv.scale),
483                    backface_policy: match m.backface_policy {
484                        crate::scene::material::BackfacePolicy::Cull => 0,
485                        crate::scene::material::BackfacePolicy::Identical => 1,
486                        crate::scene::material::BackfacePolicy::DifferentColor(_) => 2,
487                        crate::scene::material::BackfacePolicy::Tint(_) => 3,
488                        crate::scene::material::BackfacePolicy::Pattern(cfg) => {
489                            4 + cfg.pattern as u32
490                        }
491                    },
492                    backface_color: match m.backface_policy {
493                        crate::scene::material::BackfacePolicy::DifferentColor(c) => {
494                            [c[0], c[1], c[2], 1.0]
495                        }
496                        crate::scene::material::BackfacePolicy::Tint(factor) => {
497                            [factor, 0.0, 0.0, 1.0]
498                        }
499                        crate::scene::material::BackfacePolicy::Pattern(cfg) => {
500                            let world_extent = resources
501                                .mesh_store
502                                .get(item.mesh_id)
503                                .map(|mesh| {
504                                    mesh.aabb
505                                        .transformed(&glam::Mat4::from_cols_array_2d(&item.model))
506                                        .longest_side()
507                                })
508                                .unwrap_or(1.0)
509                                .max(1e-6);
510                            let world_scale = cfg.scale / world_extent;
511                            [cfg.color[0], cfg.color[1], cfg.color[2], world_scale]
512                        }
513                        _ => [0.0; 4],
514                    },
515                    has_warp: if item.warp_attribute.is_some() { 1 } else { 0 },
516                    warp_scale: item.warp_scale,
517                    _pad_warp: [0; 2],
518                };
519
520                let normal_obj_uniform = ObjectUniform {
521                    model: item.model,
522                    color: [1.0, 1.0, 1.0, 1.0],
523                    selected: 0,
524                    wireframe: 0,
525                    ambient: 0.15,
526                    diffuse: 0.75,
527                    specular: 0.4,
528                    shininess: 32.0,
529                    has_texture: 0,
530                    use_pbr: 0,
531                    metallic: 0.0,
532                    roughness: 0.5,
533                    has_normal_map: 0,
534                    has_ao_map: 0,
535                    has_attribute: 0,
536                    scalar_min: 0.0,
537                    scalar_max: 1.0,
538                    _pad_scalar: 0,
539                    nan_color: [0.0; 4],
540                    use_nan_color: 0,
541                    use_matcap: 0,
542                    matcap_blendable: 0,
543                    unlit: 0,
544                    use_face_color: 0,
545                    uv_vis_mode: 0,
546                    uv_vis_scale: 8.0,
547                    backface_policy: 0,
548                    backface_color: [0.0; 4],
549                    has_warp: 0,
550                    warp_scale: 1.0,
551                    _pad_warp: [0; 2],
552                };
553
554                // Collect per-item uniform for wireframe per-item bind groups.
555                if collect_wf_uniforms && item.visible {
556                    wireframe_uniforms.push(obj_uniform);
557                }
558
559                // Write uniform data : use get() to read buffer references, then drop.
560                {
561                    let mesh = resources
562                        .mesh_store
563                        .get(item.mesh_id)
564                        .unwrap();
565                    queue.write_buffer(
566                        &mesh.object_uniform_buf,
567                        0,
568                        bytemuck::cast_slice(&[obj_uniform]),
569                    );
570                    queue.write_buffer(
571                        &mesh.normal_uniform_buf,
572                        0,
573                        bytemuck::cast_slice(&[normal_obj_uniform]),
574                    );
575                } // mesh borrow dropped here
576
577                // Rebuild the object bind group if material/attribute/LUT/matcap/warp changed.
578                resources.update_mesh_texture_bind_group(
579                    device,
580                    item.mesh_id,
581                    item.material.texture_id,
582                    item.material.normal_map_id,
583                    item.material.ao_map_id,
584                    item.colormap_id,
585                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
586                    item.material.matcap_id,
587                    item.warp_attribute.as_deref(),
588                );
589            }
590        }
591
592        // Build per-item wireframe bind groups so each visible item gets its own
593        // object uniform, avoiding the shared-MeshId overwrite problem.
594        if !wireframe_uniforms.is_empty() {
595            let n = wireframe_uniforms.len();
596            let uniform_size = std::mem::size_of::<ObjectUniform>() as u64;
597
598            // Grow the buffer/bind-group pools if needed. We never shrink them.
599            while self.wireframe_uniform_bufs.len() < n {
600                let buf = device.create_buffer(&wgpu::BufferDescriptor {
601                    label: Some("wireframe_item_uniform"),
602                    size: uniform_size,
603                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
604                    mapped_at_creation: false,
605                });
606                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
607                    label: Some("wireframe_item_bg"),
608                    layout: &resources.object_bind_group_layout,
609                    entries: &[
610                        wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() },
611                        wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&resources.fallback_texture.view) },
612                        wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&resources.material_sampler) },
613                        wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&resources.fallback_normal_map_view) },
614                        wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&resources.fallback_ao_map_view) },
615                        wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&resources.fallback_lut_view) },
616                        wgpu::BindGroupEntry { binding: 6, resource: resources.fallback_scalar_buf.as_entire_binding() },
617                        wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(resources.fallback_matcap_view.as_ref().unwrap_or(&resources.fallback_texture.view)) },
618                        wgpu::BindGroupEntry { binding: 8, resource: resources.fallback_face_color_buf.as_entire_binding() },
619                        wgpu::BindGroupEntry { binding: 9, resource: resources.fallback_warp_buf.as_entire_binding() },
620                    ],
621                });
622                self.wireframe_uniform_bufs.push(buf);
623                self.wireframe_bind_groups.push(bg);
624            }
625
626            // Write each item's uniform into its dedicated buffer.
627            for (i, uniform) in wireframe_uniforms.iter().enumerate() {
628                queue.write_buffer(
629                    &self.wireframe_uniform_bufs[i],
630                    0,
631                    bytemuck::cast_slice(std::slice::from_ref(uniform)),
632                );
633            }
634        }
635
636        if self.use_instancing {
637            resources.ensure_instanced_pipelines(device);
638
639            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
640            // Phase 2: wireframe_mode removed from cache key : wireframe rendering
641            // uses the per-object wireframe_pipeline, not the instanced path, so
642            // instance data is now viewport-agnostic.
643            //
644            // Items with active_attribute, two-sided policy, matcap, or param_vis are
645            // excluded from the instanced batch filter. These flags are set on render
646            // items AFTER collect_render_items() (per-frame mutations), so they do NOT
647            // bump the scene generation. Use last_instancable_count as a cache key
648            // instead of a blanket has_per_frame_mutations flag; this allows scenes
649            // that mix instanced and non-instanced items (e.g. one two-sided mesh +
650            // many static boxes) to still hit the instanced batch cache on frames
651            // where the filtered set is unchanged.
652            let instancable_count = scene_items.iter().filter(|item| {
653                item.visible
654                    && item.active_attribute.is_none()
655                    && !item.material.is_two_sided()
656                    && item.material.matcap_id.is_none()
657                    && item.material.param_vis.is_none()
658                    && resources.mesh_store.get(item.mesh_id).is_some()
659            }).count();
660            let cache_valid = instancable_count == self.last_instancable_count
661                && frame.scene.generation == self.last_scene_generation
662                && frame.interaction.selection_generation == self.last_selection_generation
663                && scene_items.len() == self.last_scene_items_count;
664
665            if !cache_valid {
666                // Cache miss : rebuild batches and upload instance data.
667                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
668                    .iter()
669                    .filter(|item| {
670                        item.visible
671                            && item.active_attribute.is_none()
672                            && !item.material.is_two_sided()
673                            && item.material.matcap_id.is_none()
674                            && item.material.param_vis.is_none()
675                            && resources
676                                .mesh_store
677                                .get(item.mesh_id)
678                                .is_some()
679                    })
680                    .collect();
681
682                sorted_items.sort_unstable_by_key(|item| {
683                    (
684                        item.mesh_id.index(),
685                        item.material.texture_id,
686                        item.material.normal_map_id,
687                        item.material.ao_map_id,
688                    )
689                });
690
691                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
692                let mut all_aabbs: Vec<InstanceAabb> = Vec::with_capacity(sorted_items.len());
693                let mut batch_metas: Vec<BatchMeta> = Vec::new();
694                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
695
696                if !sorted_items.is_empty() {
697                    let mut batch_start = 0usize;
698                    for i in 1..=sorted_items.len() {
699                        let at_end = i == sorted_items.len();
700                        let key_changed = !at_end && {
701                            let a = sorted_items[batch_start];
702                            let b = sorted_items[i];
703                            a.mesh_id != b.mesh_id
704                                || a.material.texture_id != b.material.texture_id
705                                || a.material.normal_map_id != b.material.normal_map_id
706                                || a.material.ao_map_id != b.material.ao_map_id
707                        };
708
709                        if at_end || key_changed {
710                            let batch_items = &sorted_items[batch_start..i];
711                            let rep = batch_items[0];
712                            let instance_offset = all_instances.len() as u32;
713                            let is_transparent = rep.material.opacity < 1.0;
714
715                            for item in batch_items {
716                                let m = &item.material;
717                                all_instances.push(InstanceData {
718                                    model: item.model,
719                                    color: [
720                                        m.base_color[0],
721                                        m.base_color[1],
722                                        m.base_color[2],
723                                        m.opacity,
724                                    ],
725                                    selected: if item.selected { 1 } else { 0 },
726                                    wireframe: 0, // Phase 2: always 0 : wireframe uses per-object pipeline
727                                    ambient: m.ambient,
728                                    diffuse: m.diffuse,
729                                    specular: m.specular,
730                                    shininess: m.shininess,
731                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
732                                    use_pbr: if m.use_pbr { 1 } else { 0 },
733                                    metallic: m.metallic,
734                                    roughness: m.roughness,
735                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
736                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
737                                    unlit: if m.unlit { 1 } else { 0 },
738                                    _pad_inst: [0; 3],
739                                });
740                            }
741
742                            // Build per-instance AABBs alongside instance data.
743                            // All items in a batch share the same mesh_id (batch key), so
744                            // mesh.index_count is the same for every item — look it up once.
745                            let batch_idx = instanced_batches.len() as u32;
746                            let mesh_index_count = resources
747                                .mesh_store
748                                .get(rep.mesh_id)
749                                .map(|m| m.index_count)
750                                .unwrap_or(0);
751                            for item in batch_items {
752                                if let Some(mesh) = resources.mesh_store.get(item.mesh_id) {
753                                    let model =
754                                        glam::Mat4::from_cols_array_2d(&item.model);
755                                    let world_aabb = mesh.aabb.transformed(&model);
756                                    all_aabbs.push(InstanceAabb {
757                                        min: world_aabb.min.into(),
758                                        batch_index: batch_idx,
759                                        max: world_aabb.max.into(),
760                                        _pad: 0,
761                                    });
762                                }
763                            }
764
765                            // vis_offset is the prefix sum of instance counts; since
766                            // instances are laid out contiguously per batch, it equals
767                            // instance_offset.
768                            batch_metas.push(BatchMeta {
769                                index_count: mesh_index_count,
770                                first_index: 0,
771                                instance_offset,
772                                instance_count: batch_items.len() as u32,
773                                vis_offset: instance_offset,
774                                is_transparent: if is_transparent { 1 } else { 0 },
775                                _pad: [0, 0],
776                            });
777
778                            instanced_batches.push(InstancedBatch {
779                                mesh_id: rep.mesh_id,
780                                texture_id: rep.material.texture_id,
781                                normal_map_id: rep.material.normal_map_id,
782                                ao_map_id: rep.material.ao_map_id,
783                                instance_offset,
784                                instance_count: batch_items.len() as u32,
785                                is_transparent,
786                            });
787
788                            batch_start = i;
789                        }
790                    }
791                }
792
793                self.cached_instance_data = all_instances;
794                self.cached_instanced_batches = instanced_batches;
795
796                resources.upload_instance_data(device, queue, &self.cached_instance_data);
797                resources.upload_aabb_and_batch_meta(device, queue, &all_aabbs, &batch_metas);
798
799                self.instanced_batches = self.cached_instanced_batches.clone();
800
801                self.last_scene_generation = frame.scene.generation;
802                self.last_selection_generation = frame.interaction.selection_generation;
803                self.last_scene_items_count = scene_items.len();
804                self.last_instancable_count = sorted_items.len();
805
806                for batch in &self.instanced_batches {
807                    resources.get_instance_bind_group(
808                        device,
809                        batch.texture_id,
810                        batch.normal_map_id,
811                        batch.ao_map_id,
812                    );
813                }
814            } else {
815                for batch in &self.instanced_batches {
816                    resources.get_instance_bind_group(
817                        device,
818                        batch.texture_id,
819                        batch.normal_map_id,
820                        batch.ao_map_id,
821                    );
822                }
823            }
824
825            // ------------------------------------------------------------------
826            // GPU cull dispatch (Phase 3)
827            //
828            // Run `cull_instances` + `write_indirect_args` whenever GPU culling
829            // is active and all required buffers are allocated.
830            // ------------------------------------------------------------------
831            if self.gpu_culling_enabled
832                && !self.instanced_batches.is_empty()
833                && !self.cached_instance_data.is_empty()
834            {
835                let instance_count = self.cached_instance_data.len() as u32;
836                let batch_count = self.instanced_batches.len() as u32;
837
838                // Do all mutable borrows before taking immutable borrows from resources.
839                if self.cull_resources.is_none() {
840                    self.cull_resources =
841                        Some(crate::renderer::indirect::CullResources::new(device));
842                }
843                resources.ensure_cull_instance_pipelines(device);
844                for batch in &self.instanced_batches.clone() {
845                    resources.get_instance_cull_bind_group(
846                        device,
847                        batch.texture_id,
848                        batch.normal_map_id,
849                        batch.ao_map_id,
850                    );
851                }
852
853                // Now take immutable borrows to the GPU buffers for dispatch.
854                if let (
855                    Some(aabb_buf),
856                    Some(meta_buf),
857                    Some(counter_buf),
858                    Some(vis_buf),
859                    Some(indirect_buf),
860                ) = (
861                    resources.instance_aabb_buf.as_ref(),
862                    resources.batch_meta_buf.as_ref(),
863                    resources.batch_counter_buf.as_ref(),
864                    resources.visibility_index_buf.as_ref(),
865                    resources.indirect_args_buf.as_ref(),
866                ) {
867                    // Build the FrustumUniform from the current camera view-projection.
868                    let vp_mat = frame.camera.render_camera.view_proj();
869                    let cpu_frustum =
870                        crate::camera::frustum::Frustum::from_view_proj(&vp_mat);
871                    let frustum_uniform = crate::resources::FrustumUniform {
872                        planes: std::array::from_fn(|i| crate::resources::FrustumPlane {
873                            normal: cpu_frustum.planes[i].normal.into(),
874                            distance: cpu_frustum.planes[i].d,
875                        }),
876                        instance_count,
877                        batch_count,
878                        _pad: [0; 2],
879                    };
880
881                    let cull = self.cull_resources.as_ref().unwrap();
882                    let mut encoder =
883                        device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
884                            label: Some("cull_encoder"),
885                        });
886                    cull.dispatch(
887                        &mut encoder,
888                        device,
889                        queue,
890                        &frustum_uniform,
891                        aabb_buf,
892                        meta_buf,
893                        counter_buf,
894                        vis_buf,
895                        indirect_buf,
896                        instance_count,
897                        batch_count,
898                    );
899
900                    // Copy indirect_args_buf to the CPU-readable staging buffer so the
901                    // visible instance count can be read back next frame (one-frame lag).
902                    let indirect_bytes = batch_count as u64 * 20;
903                    if self
904                        .indirect_readback_buf
905                        .as_ref()
906                        .map_or(0, |b| b.size())
907                        < indirect_bytes
908                    {
909                        self.indirect_readback_buf =
910                            Some(device.create_buffer(&wgpu::BufferDescriptor {
911                                label: Some("indirect_readback_buf"),
912                                size: indirect_bytes,
913                                usage: wgpu::BufferUsages::COPY_DST
914                                    | wgpu::BufferUsages::MAP_READ,
915                                mapped_at_creation: false,
916                            }));
917                    }
918                    if let Some(ref rb_buf) = self.indirect_readback_buf {
919                        encoder.copy_buffer_to_buffer(
920                            indirect_buf,
921                            0,
922                            rb_buf,
923                            0,
924                            indirect_bytes,
925                        );
926                    }
927                    queue.submit(std::iter::once(encoder.finish()));
928                    self.indirect_readback_batch_count = batch_count;
929                    self.indirect_readback_pending = true;
930                }
931            }
932        }
933
934        // ------------------------------------------------------------------
935        // SciVis Phase B : point cloud and glyph GPU data upload.
936        // ------------------------------------------------------------------
937        self.point_cloud_gpu_data.clear();
938        if !frame.scene.point_clouds.is_empty() {
939            resources.ensure_point_cloud_pipeline(device);
940            for item in &frame.scene.point_clouds {
941                if item.positions.is_empty() {
942                    continue;
943                }
944                let gpu_data = resources.upload_point_cloud(device, queue, item);
945                self.point_cloud_gpu_data.push(gpu_data);
946            }
947        }
948
949        self.glyph_gpu_data.clear();
950        if !frame.scene.glyphs.is_empty() {
951            resources.ensure_glyph_pipeline(device);
952            for item in &frame.scene.glyphs {
953                if item.positions.is_empty() || item.vectors.is_empty() {
954                    continue;
955                }
956                let gpu_data = resources.upload_glyph_set(device, queue, item);
957                self.glyph_gpu_data.push(gpu_data);
958            }
959        }
960
961        // ------------------------------------------------------------------
962        // Sprite billboard GPU data upload.
963        // ------------------------------------------------------------------
964        self.sprite_gpu_data.clear();
965        if !frame.scene.sprite_items.is_empty() {
966            resources.ensure_sprite_pipelines(device);
967            for item in &frame.scene.sprite_items {
968                if item.positions.is_empty() {
969                    continue;
970                }
971                let gd = resources.upload_sprite(device, queue, item);
972                self.sprite_gpu_data.push(gd);
973            }
974        }
975
976        // ------------------------------------------------------------------
977        // SciVis Phase 5 : tensor glyph GPU data upload.
978        // ------------------------------------------------------------------
979        self.tensor_glyph_gpu_data.clear();
980        if !frame.scene.tensor_glyphs.is_empty() {
981            resources.ensure_tensor_glyph_pipeline(device);
982            for item in &frame.scene.tensor_glyphs {
983                if item.positions.is_empty() {
984                    continue;
985                }
986                let gd = resources.upload_tensor_glyph_set(device, queue, item);
987                self.tensor_glyph_gpu_data.push(gd);
988            }
989        }
990
991        // ------------------------------------------------------------------
992        // SciVis Phase M8 : polyline GPU data upload.
993        // ------------------------------------------------------------------
994        self.polyline_gpu_data.clear();
995        let vp_size = frame.camera.viewport_size;
996        if !frame.scene.polylines.is_empty() {
997            resources.ensure_polyline_pipeline(device);
998            for item in &frame.scene.polylines {
999                if item.positions.is_empty() {
1000                    continue;
1001                }
1002                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
1003                self.polyline_gpu_data.push(gpu_data);
1004
1005                // Phase 11: auto-generate GlyphItems for node/edge vector quantities.
1006                if !item.node_vectors.is_empty() {
1007                    resources.ensure_glyph_pipeline(device);
1008                    let g = crate::quantities::polyline_node_vectors_to_glyphs(item);
1009                    if !g.positions.is_empty() {
1010                        let gd = resources.upload_glyph_set(device, queue, &g);
1011                        self.glyph_gpu_data.push(gd);
1012                    }
1013                }
1014                if !item.edge_vectors.is_empty() {
1015                    resources.ensure_glyph_pipeline(device);
1016                    let g = crate::quantities::polyline_edge_vectors_to_glyphs(item);
1017                    if !g.positions.is_empty() {
1018                        let gd = resources.upload_glyph_set(device, queue, &g);
1019                        self.glyph_gpu_data.push(gd);
1020                    }
1021                }
1022            }
1023        }
1024
1025        // ------------------------------------------------------------------
1026        // SciVis Phase L : isoline extraction and upload via polyline pipeline.
1027        // ------------------------------------------------------------------
1028        if !frame.scene.isolines.is_empty() {
1029            resources.ensure_polyline_pipeline(device);
1030            for item in &frame.scene.isolines {
1031                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
1032                    continue;
1033                }
1034                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
1035                if positions.is_empty() {
1036                    continue;
1037                }
1038                let polyline = PolylineItem {
1039                    positions,
1040                    scalars: Vec::new(),
1041                    strip_lengths,
1042                    scalar_range: None,
1043                    colormap_id: None,
1044                    default_color: item.color,
1045                    line_width: item.line_width,
1046                    id: 0,
1047                    ..Default::default()
1048                };
1049                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
1050                self.polyline_gpu_data.push(gpu_data);
1051            }
1052        }
1053
1054        // ------------------------------------------------------------------
1055        // Phase 10A : camera frustum wireframes (converted to polylines).
1056        // ------------------------------------------------------------------
1057        if !frame.scene.camera_frustums.is_empty() {
1058            resources.ensure_polyline_pipeline(device);
1059            for item in &frame.scene.camera_frustums {
1060                let polyline = item.to_polyline();
1061                if !polyline.positions.is_empty() {
1062                    let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
1063                    self.polyline_gpu_data.push(gpu_data);
1064                }
1065            }
1066        }
1067
1068        // ------------------------------------------------------------------
1069        // Phase 16 : GPU implicit surface items.
1070        // ------------------------------------------------------------------
1071        self.implicit_gpu_data.clear();
1072        if !frame.scene.gpu_implicit.is_empty() {
1073            resources.ensure_implicit_pipeline(device);
1074            for item in &frame.scene.gpu_implicit {
1075                if item.primitives.is_empty() {
1076                    continue;
1077                }
1078                let gpu = resources.upload_implicit_item(device, item);
1079                self.implicit_gpu_data.push(gpu);
1080            }
1081        }
1082
1083        // ------------------------------------------------------------------
1084        // Phase 17 : GPU marching cubes compute dispatch.
1085        // ------------------------------------------------------------------
1086        self.mc_gpu_data.clear();
1087        if !frame.scene.gpu_mc_jobs.is_empty() {
1088            resources.ensure_mc_pipelines(device);
1089            self.mc_gpu_data =
1090                resources.run_mc_jobs(device, queue, &frame.scene.gpu_mc_jobs);
1091        }
1092
1093        // ------------------------------------------------------------------
1094        // Phase 10B : screen-space image overlays.
1095        // ------------------------------------------------------------------
1096        self.screen_image_gpu_data.clear();
1097        if !frame.scene.screen_images.is_empty() {
1098            resources.ensure_screen_image_pipeline(device);
1099            // Phase 12: ensure dc pipeline if any item carries depth data.
1100            if frame.scene.screen_images.iter().any(|i| i.depth.is_some()) {
1101                resources.ensure_screen_image_dc_pipeline(device);
1102            }
1103            let vp_w = vp_size[0];
1104            let vp_h = vp_size[1];
1105            for item in &frame.scene.screen_images {
1106                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
1107                    continue;
1108                }
1109                let gpu = resources.upload_screen_image(device, queue, item, vp_w, vp_h);
1110                self.screen_image_gpu_data.push(gpu);
1111            }
1112        }
1113
1114        // ------------------------------------------------------------------
1115        // Phase 7 : overlay image overlays (OverlayFrame).
1116        // ------------------------------------------------------------------
1117        self.overlay_image_gpu_data.clear();
1118        if !frame.overlays.images.is_empty() {
1119            resources.ensure_screen_image_pipeline(device);
1120            let vp_w = vp_size[0];
1121            let vp_h = vp_size[1];
1122            for item in &frame.overlays.images {
1123                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
1124                    continue;
1125                }
1126                let gpu = resources.upload_overlay_image(device, queue, item, vp_w, vp_h);
1127                self.overlay_image_gpu_data.push(gpu);
1128            }
1129        }
1130
1131        // ------------------------------------------------------------------
1132        // SciVis Phase M : streamtube GPU data upload.
1133        // ------------------------------------------------------------------
1134        self.streamtube_gpu_data.clear();
1135        if !frame.scene.streamtube_items.is_empty() {
1136            resources.ensure_streamtube_pipeline(device);
1137            for item in &frame.scene.streamtube_items {
1138                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1139                    continue;
1140                }
1141                let gpu_data = resources.upload_streamtube(device, queue, item);
1142                if gpu_data.index_count > 0 {
1143                    self.streamtube_gpu_data.push(gpu_data);
1144                }
1145            }
1146        }
1147
1148        // ------------------------------------------------------------------
1149        // Phase 3.3 : General Tube GPU data upload.
1150        // ------------------------------------------------------------------
1151        self.tube_gpu_data.clear();
1152        if !frame.scene.tube_items.is_empty() {
1153            resources.ensure_streamtube_pipeline(device);
1154            for item in &frame.scene.tube_items {
1155                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1156                    continue;
1157                }
1158                let gpu_data = resources.upload_tube(device, queue, item);
1159                if gpu_data.index_count > 0 {
1160                    self.tube_gpu_data.push(gpu_data);
1161                }
1162            }
1163        }
1164
1165        // ------------------------------------------------------------------
1166        // Phase 8.1 : Ribbon GPU data upload.
1167        // ------------------------------------------------------------------
1168        self.ribbon_gpu_data.clear();
1169        if !frame.scene.ribbon_items.is_empty() {
1170            resources.ensure_streamtube_pipeline(device);
1171            for item in &frame.scene.ribbon_items {
1172                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1173                    continue;
1174                }
1175                let gpu_data = resources.upload_ribbon(device, queue, item);
1176                if gpu_data.index_count > 0 {
1177                    self.ribbon_gpu_data.push(gpu_data);
1178                }
1179            }
1180        }
1181
1182        // ------------------------------------------------------------------
1183        // Phase 3.2 : Image Slice GPU data upload.
1184        // ------------------------------------------------------------------
1185        self.image_slice_gpu_data.clear();
1186        if !frame.scene.image_slices.is_empty() {
1187            resources.ensure_image_slice_pipeline(device);
1188            for item in &frame.scene.image_slices {
1189                if let Some(gpu_data) = resources.upload_image_slice(device, queue, item) {
1190                    self.image_slice_gpu_data.push(gpu_data);
1191                }
1192            }
1193        }
1194
1195        // ------------------------------------------------------------------
1196        // Phase 10 : Volume Surface Slice GPU data upload.
1197        // ------------------------------------------------------------------
1198        self.volume_surface_slice_gpu_data.clear();
1199        if !frame.scene.volume_surface_slices.is_empty() {
1200            resources.ensure_volume_surface_slice_pipeline(device);
1201            for item in &frame.scene.volume_surface_slices {
1202                if let Some(gpu_data) = resources.upload_volume_surface_slice(device, queue, item) {
1203                    self.volume_surface_slice_gpu_data.push(gpu_data);
1204                }
1205            }
1206        }
1207
1208        // ------------------------------------------------------------------
1209        // Phase 4: Surface LIC GPU data upload.
1210        // ------------------------------------------------------------------
1211        self.lic_gpu_data.clear();
1212        if !frame.scene.lic_items.is_empty() {
1213            // The LIC surface pipeline is created inside ensure_hdr_shared (already called before
1214            // prepare_scene_internal runs), so no separate ensure call is needed here.
1215            for item in &frame.scene.lic_items {
1216                if item.vector_attribute.is_empty() {
1217                    continue;
1218                }
1219                if let Some(mesh) = resources.mesh_store.get(item.mesh_id) {
1220                    // Verify the vector attribute buffer exists before committing to this item.
1221                    if mesh.vector_attribute_buffers.contains_key(&item.vector_attribute) {
1222                        if let Some(bgl) = &resources.lic_surface_bgl {
1223                            use crate::resources::LicObjectUniform;
1224                            let model = item.model;
1225                            let obj_data = LicObjectUniform { model };
1226                            let obj_buf = device.create_buffer(&wgpu::BufferDescriptor {
1227                                label: Some("lic_object_uniform"),
1228                                size: std::mem::size_of::<LicObjectUniform>() as u64,
1229                                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1230                                mapped_at_creation: false,
1231                            });
1232                            queue.write_buffer(&obj_buf, 0, bytemuck::cast_slice(&[obj_data]));
1233                            // Bind group (group 1): object uniform only.
1234                            // Flow vectors are bound as vertex buffer 1 in the render pass.
1235                            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1236                                label: Some("lic_surface_item_bg"),
1237                                layout: bgl,
1238                                entries: &[
1239                                    wgpu::BindGroupEntry {
1240                                        binding: 0,
1241                                        resource: obj_buf.as_entire_binding(),
1242                                    },
1243                                ],
1244                            });
1245                            self.lic_gpu_data.push(crate::resources::LicSurfaceGpuData {
1246                                bind_group: bg,
1247                                _object_uniform_buf: obj_buf,
1248                                mesh_id: item.mesh_id,
1249                                vector_attribute: item.vector_attribute.clone(),
1250                            });
1251                        }
1252                    }
1253                }
1254            }
1255            // Write LicAdvectUniform to the per-viewport buffer.
1256            if let Some(hdr) = self.viewport_slots[frame.camera.viewport_index].hdr.as_ref() {
1257                if let Some(first) = frame.scene.lic_items.first() {
1258                    let [vw, vh] = hdr.size;
1259                    let u = crate::resources::LicAdvectUniform {
1260                        steps: first.config.steps,
1261                        step_size: first.config.step_size,
1262                        vp_width: vw as f32,
1263                        vp_height: vh as f32,
1264                    };
1265                    queue.write_buffer(&hdr.lic_uniform_buf, 0, bytemuck::cast_slice(&[u]));
1266                }
1267            }
1268        }
1269
1270        // ------------------------------------------------------------------
1271        // SciVis Phase D : volume GPU data upload.
1272        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
1273        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
1274        // ------------------------------------------------------------------
1275        self.volume_gpu_data.clear();
1276        if !frame.scene.volumes.is_empty() {
1277            resources.ensure_volume_pipeline(device);
1278            let clip_objects_for_vol = &frame.effects.clip_objects;
1279            // Phase 5: under budget pressure with allow_volume_quality_reduction, double the
1280            // step size (half the sample count) to reduce GPU raymarch cost.
1281            let vol_step_multiplier = if self.degradation_volume_quality_reduced {
1282                2.0_f32
1283            } else {
1284                1.0_f32
1285            };
1286            for item in &frame.scene.volumes {
1287                let gpu = resources.upload_volume_frame(
1288                    device,
1289                    queue,
1290                    item,
1291                    clip_objects_for_vol,
1292                    vol_step_multiplier,
1293                );
1294                self.volume_gpu_data.push(gpu);
1295            }
1296        }
1297
1298        // -- Frame stats --
1299        {
1300            let total = scene_items.len() as u32;
1301            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
1302            let mut draw_calls = 0u32;
1303            let mut triangles = 0u64;
1304            let instanced_batch_count = if self.use_instancing {
1305                self.instanced_batches.len() as u32
1306            } else {
1307                0
1308            };
1309
1310            if self.use_instancing {
1311                for batch in &self.instanced_batches {
1312                    if let Some(mesh) = resources
1313                        .mesh_store
1314                        .get(batch.mesh_id)
1315                    {
1316                        draw_calls += 1;
1317                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
1318                    }
1319                }
1320            } else {
1321                for item in scene_items {
1322                    if !item.visible {
1323                        continue;
1324                    }
1325                    if let Some(mesh) = resources
1326                        .mesh_store
1327                        .get(item.mesh_id)
1328                    {
1329                        draw_calls += 1;
1330                        triangles += (mesh.index_count / 3) as u64;
1331                    }
1332                }
1333            }
1334
1335            self.last_stats = crate::renderer::stats::FrameStats {
1336                total_objects: total,
1337                visible_objects: visible,
1338                culled_objects: total.saturating_sub(visible),
1339                draw_calls,
1340                instanced_batches: instanced_batch_count,
1341                triangles_submitted: triangles,
1342                shadow_draw_calls: 0, // Updated below in shadow pass.
1343                gpu_culling_active: self.gpu_culling_enabled,
1344                // Clear stale readback if GPU culling is off this frame.
1345                gpu_visible_instances: if self.gpu_culling_enabled {
1346                    self.last_stats.gpu_visible_instances
1347                } else {
1348                    None
1349                },
1350                ..self.last_stats
1351            };
1352        }
1353
1354        // ------------------------------------------------------------------
1355        // Shadow depth pass : CSM: render each cascade into its atlas tile.
1356        // Phase 5: skip the pass entirely when over budget and shadow reduction is allowed.
1357        // ------------------------------------------------------------------
1358        let skip_shadows = self.degradation_shadows_skipped;
1359
1360        // When skipping the shadow pass (budget pressure or empty scene), clear the
1361        // atlas to max depth so that stale values from a previous frame or a previous
1362        // showcase don't produce phantom shadows.
1363        if lighting.shadows_enabled && (skip_shadows || scene_items.is_empty()) {
1364            let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1365                label: Some("shadow_clear_encoder"),
1366            });
1367            let _ = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
1368                label: Some("shadow_clear_pass"),
1369                color_attachments: &[],
1370                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1371                    view: &resources.shadow_map_view,
1372                    depth_ops: Some(wgpu::Operations {
1373                        load: wgpu::LoadOp::Clear(1.0),
1374                        store: wgpu::StoreOp::Store,
1375                    }),
1376                    stencil_ops: None,
1377                }),
1378                timestamp_writes: None,
1379                occlusion_query_set: None,
1380            });
1381            queue.submit(std::iter::once(enc.finish()));
1382        }
1383
1384        if lighting.shadows_enabled && !scene_items.is_empty() && !skip_shadows {
1385            // ------------------------------------------------------------------
1386            // Shadow GPU cull dispatch (Phase 4)
1387            //
1388            // For each active cascade, dispatch `cull_instances` + `write_indirect_args`
1389            // with the cascade frustum. Results land in `shadow_vis_bufs[c]` and
1390            // `shadow_indirect_bufs[c]`, consumed by the shadow render pass below.
1391            // All cascade dispatches share the same `batch_counter_buf`; each
1392            // `write_indirect_args` dispatch resets the counters for the next cascade.
1393            // ------------------------------------------------------------------
1394            if self.gpu_culling_enabled
1395                && self.use_instancing
1396                && !self.instanced_batches.is_empty()
1397                && !self.cached_instance_data.is_empty()
1398            {
1399                // Mutable operations first.
1400                if self.cull_resources.is_none() {
1401                    self.cull_resources =
1402                        Some(crate::renderer::indirect::CullResources::new(device));
1403                }
1404                resources.ensure_cull_instance_pipelines(device);
1405                for c in 0..effective_cascade_count {
1406                    resources.get_shadow_cull_instance_bind_group(device, c);
1407                }
1408
1409                let instance_count = self.cached_instance_data.len() as u32;
1410                let batch_count = self.instanced_batches.len() as u32;
1411
1412                if let (Some(aabb_buf), Some(meta_buf), Some(counter_buf)) = (
1413                    resources.instance_aabb_buf.as_ref(),
1414                    resources.batch_meta_buf.as_ref(),
1415                    resources.batch_counter_buf.as_ref(),
1416                ) {
1417                    let cull = self.cull_resources.as_ref().unwrap();
1418                    let mut shadow_cull_encoder =
1419                        device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1420                            label: Some("shadow_cull_encoder"),
1421                        });
1422                    for c in 0..effective_cascade_count {
1423                        if let (Some(shadow_vis_buf), Some(shadow_indirect_buf)) = (
1424                            resources.shadow_vis_bufs[c].as_ref(),
1425                            resources.shadow_indirect_bufs[c].as_ref(),
1426                        ) {
1427                            let cpu_frustum =
1428                                crate::camera::frustum::Frustum::from_view_proj(
1429                                    &cascade_view_projs[c],
1430                                );
1431                            let frustum_uniform = crate::resources::FrustumUniform {
1432                                planes: std::array::from_fn(|i| crate::resources::FrustumPlane {
1433                                    normal: cpu_frustum.planes[i].normal.into(),
1434                                    distance: cpu_frustum.planes[i].d,
1435                                }),
1436                                instance_count,
1437                                batch_count,
1438                                _pad: [0; 2],
1439                            };
1440                            cull.dispatch_shadow(
1441                                &mut shadow_cull_encoder,
1442                                device,
1443                                queue,
1444                                c,
1445                                &frustum_uniform,
1446                                aabb_buf,
1447                                meta_buf,
1448                                counter_buf,
1449                                shadow_vis_buf,
1450                                shadow_indirect_buf,
1451                                instance_count,
1452                                batch_count,
1453                            );
1454                        }
1455                    }
1456                    queue.submit(std::iter::once(shadow_cull_encoder.finish()));
1457                }
1458            }
1459
1460            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1461                label: Some("shadow_pass_encoder"),
1462            });
1463            {
1464                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1465                    label: Some("shadow_pass"),
1466                    color_attachments: &[],
1467                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1468                        view: &resources.shadow_map_view,
1469                        depth_ops: Some(wgpu::Operations {
1470                            load: wgpu::LoadOp::Clear(1.0),
1471                            store: wgpu::StoreOp::Store,
1472                        }),
1473                        stencil_ops: None,
1474                    }),
1475                    timestamp_writes: None,
1476                    occlusion_query_set: None,
1477                });
1478
1479                let mut shadow_draws = 0u32;
1480                let tile_px = tile_size as f32;
1481
1482                if self.use_instancing {
1483                    let use_shadow_indirect = self.gpu_culling_enabled
1484                        && resources.shadow_instanced_cull_pipeline.is_some()
1485                        && resources.shadow_vis_bufs[0].is_some();
1486
1487                    if use_shadow_indirect {
1488                        // GPU-culled indirect shadow path (Phase 4).
1489                        for cascade in 0..effective_cascade_count {
1490                            let tile_col = (cascade % 2) as f32;
1491                            let tile_row = (cascade / 2) as f32;
1492                            shadow_pass.set_viewport(
1493                                tile_col * tile_px,
1494                                tile_row * tile_px,
1495                                tile_px,
1496                                tile_px,
1497                                0.0,
1498                                1.0,
1499                            );
1500                            shadow_pass.set_scissor_rect(
1501                                (tile_col * tile_px) as u32,
1502                                (tile_row * tile_px) as u32,
1503                                tile_size,
1504                                tile_size,
1505                            );
1506
1507                            // Write cascade view-projection matrix.
1508                            queue.write_buffer(
1509                                resources.shadow_instanced_cascade_bufs[cascade]
1510                                    .as_ref()
1511                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1512                                0,
1513                                bytemuck::cast_slice(
1514                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1515                                ),
1516                            );
1517
1518                            let Some(pipeline) =
1519                                resources.shadow_instanced_cull_pipeline.as_ref()
1520                            else {
1521                                continue;
1522                            };
1523                            let Some(cascade_bg) =
1524                                resources.shadow_instanced_cascade_bgs[cascade].as_ref()
1525                            else {
1526                                continue;
1527                            };
1528                            let Some(inst_cull_bg) =
1529                                resources.shadow_cull_instance_bgs[cascade].as_ref()
1530                            else {
1531                                continue;
1532                            };
1533                            let Some(shadow_indirect_buf) =
1534                                resources.shadow_indirect_bufs[cascade].as_ref()
1535                            else {
1536                                continue;
1537                            };
1538
1539                            shadow_pass.set_pipeline(pipeline);
1540                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1541                            shadow_pass.set_bind_group(1, inst_cull_bg, &[]);
1542
1543                            for (bi, batch) in self.instanced_batches.iter().enumerate() {
1544                                if batch.is_transparent {
1545                                    continue;
1546                                }
1547                                let Some(mesh) = resources.mesh_store.get(batch.mesh_id) else {
1548                                    continue;
1549                                };
1550                                shadow_pass
1551                                    .set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1552                                shadow_pass.set_index_buffer(
1553                                    mesh.index_buffer.slice(..),
1554                                    wgpu::IndexFormat::Uint32,
1555                                );
1556                                shadow_pass.draw_indexed_indirect(
1557                                    shadow_indirect_buf,
1558                                    bi as u64 * 20,
1559                                );
1560                                shadow_draws += 1;
1561                            }
1562                        }
1563                    } else if let (Some(pipeline), Some(instance_bg)) = (
1564                        &resources.shadow_instanced_pipeline,
1565                        self.instanced_batches.first().and_then(|b| {
1566                            resources.instance_bind_groups.get(&(
1567                                b.texture_id.unwrap_or(u64::MAX),
1568                                b.normal_map_id.unwrap_or(u64::MAX),
1569                                b.ao_map_id.unwrap_or(u64::MAX),
1570                            ))
1571                        }),
1572                    ) {
1573                        // Direct draw shadow path (fallback when GPU culling is off).
1574                        for cascade in 0..effective_cascade_count {
1575                            let tile_col = (cascade % 2) as f32;
1576                            let tile_row = (cascade / 2) as f32;
1577                            shadow_pass.set_viewport(
1578                                tile_col * tile_px,
1579                                tile_row * tile_px,
1580                                tile_px,
1581                                tile_px,
1582                                0.0,
1583                                1.0,
1584                            );
1585                            shadow_pass.set_scissor_rect(
1586                                (tile_col * tile_px) as u32,
1587                                (tile_row * tile_px) as u32,
1588                                tile_size,
1589                                tile_size,
1590                            );
1591
1592                            shadow_pass.set_pipeline(pipeline);
1593
1594                            queue.write_buffer(
1595                                resources.shadow_instanced_cascade_bufs[cascade]
1596                                    .as_ref()
1597                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1598                                0,
1599                                bytemuck::cast_slice(
1600                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1601                                ),
1602                            );
1603
1604                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
1605                                .as_ref()
1606                                .expect("shadow_instanced_cascade_bgs not allocated");
1607                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1608                            shadow_pass.set_bind_group(1, instance_bg, &[]);
1609
1610                            for batch in &self.instanced_batches {
1611                                if batch.is_transparent {
1612                                    continue;
1613                                }
1614                                let Some(mesh) = resources
1615                                    .mesh_store
1616                                    .get(batch.mesh_id)
1617                                else {
1618                                    continue;
1619                                };
1620                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1621                                shadow_pass.set_index_buffer(
1622                                    mesh.index_buffer.slice(..),
1623                                    wgpu::IndexFormat::Uint32,
1624                                );
1625                                shadow_pass.draw_indexed(
1626                                    0..mesh.index_count,
1627                                    0,
1628                                    batch.instance_offset
1629                                        ..batch.instance_offset + batch.instance_count,
1630                                );
1631                                shadow_draws += 1;
1632                            }
1633                        }
1634                    }
1635                } else {
1636                    for cascade in 0..effective_cascade_count {
1637                        let tile_col = (cascade % 2) as f32;
1638                        let tile_row = (cascade / 2) as f32;
1639                        shadow_pass.set_viewport(
1640                            tile_col * tile_px,
1641                            tile_row * tile_px,
1642                            tile_px,
1643                            tile_px,
1644                            0.0,
1645                            1.0,
1646                        );
1647                        shadow_pass.set_scissor_rect(
1648                            (tile_col * tile_px) as u32,
1649                            (tile_row * tile_px) as u32,
1650                            tile_size,
1651                            tile_size,
1652                        );
1653
1654                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
1655                        shadow_pass.set_bind_group(
1656                            0,
1657                            &resources.shadow_bind_group,
1658                            &[cascade as u32 * 256],
1659                        );
1660
1661                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
1662                            &cascade_view_projs[cascade],
1663                        );
1664
1665                        for item in scene_items.iter() {
1666                            if !item.visible {
1667                                continue;
1668                            }
1669                            if item.material.opacity < 1.0 {
1670                                continue;
1671                            }
1672                            let Some(mesh) = resources
1673                                .mesh_store
1674                                .get(item.mesh_id)
1675                            else {
1676                                continue;
1677                            };
1678
1679                            let world_aabb = mesh
1680                                .aabb
1681                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1682                            if cascade_frustum.cull_aabb(&world_aabb) {
1683                                continue;
1684                            }
1685
1686                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1687                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1688                            shadow_pass.set_index_buffer(
1689                                mesh.index_buffer.slice(..),
1690                                wgpu::IndexFormat::Uint32,
1691                            );
1692                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1693                            shadow_draws += 1;
1694                        }
1695                    }
1696                }
1697                drop(shadow_pass);
1698                self.last_stats.shadow_draw_calls = shadow_draws;
1699            }
1700            queue.submit(std::iter::once(encoder.finish()));
1701        }
1702    }
1703
1704    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
1705    ///
1706    /// Call once per viewport per frame, after `prepare_scene_internal`.
1707    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
1708    pub(super) fn prepare_viewport_internal(
1709        &mut self,
1710        device: &wgpu::Device,
1711        queue: &wgpu::Queue,
1712        frame: &FrameData,
1713        viewport_fx: &ViewportEffects<'_>,
1714    ) {
1715        // Ensure a per-viewport camera slot exists for this viewport index.
1716        // Must happen before the `resources` borrow below.
1717        self.ensure_viewport_slot(device, frame.camera.viewport_index);
1718
1719        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1720            SurfaceSubmission::Flat(items) => items.as_ref(),
1721        };
1722
1723        // Capture before the resources mutable borrow so it's accessible inside the block.
1724        let gp_cascade0_mat = self.last_cascade0_shadow_mat.to_cols_array_2d();
1725
1726        {
1727            let resources = &mut self.resources;
1728
1729            // Upload clip planes + clip volume uniforms from clip_objects.
1730            {
1731                let mut planes = [[0.0f32; 4]; 6];
1732                let mut count = 0u32;
1733                let mut clip_vols_uniform: ClipVolumesUniform = bytemuck::Zeroable::zeroed();
1734
1735                for obj in viewport_fx
1736                    .clip_objects
1737                    .iter()
1738                    .filter(|o| o.enabled && o.clip_geometry)
1739                {
1740                    match obj.shape {
1741                        ClipShape::Plane {
1742                            normal, distance, ..
1743                        } if count < 6 => {
1744                            planes[count as usize] = [normal[0], normal[1], normal[2], distance];
1745                            count += 1;
1746                        }
1747                        ClipShape::Box {
1748                            center,
1749                            half_extents,
1750                            orientation,
1751                        } if (clip_vols_uniform.count as usize) < CLIP_VOLUME_MAX => {
1752                            let idx = clip_vols_uniform.count as usize;
1753                            clip_vols_uniform.volumes[idx] =
1754                                ClipVolumeEntry::from_box(center, half_extents, orientation);
1755                            clip_vols_uniform.count += 1;
1756                        }
1757                        ClipShape::Sphere { center, radius }
1758                            if (clip_vols_uniform.count as usize) < CLIP_VOLUME_MAX =>
1759                        {
1760                            let idx = clip_vols_uniform.count as usize;
1761                            clip_vols_uniform.volumes[idx] =
1762                                ClipVolumeEntry::from_sphere(center, radius);
1763                            clip_vols_uniform.count += 1;
1764                        }
1765                        ClipShape::Cylinder {
1766                            center,
1767                            axis,
1768                            radius,
1769                            half_length,
1770                        } if (clip_vols_uniform.count as usize) < CLIP_VOLUME_MAX => {
1771                            let idx = clip_vols_uniform.count as usize;
1772                            clip_vols_uniform.volumes[idx] =
1773                                ClipVolumeEntry::from_cylinder(center, axis, radius, half_length);
1774                            clip_vols_uniform.count += 1;
1775                        }
1776                        _ => {}
1777                    }
1778                }
1779
1780                let clip_uniform = ClipPlanesUniform {
1781                    planes,
1782                    count,
1783                    _pad0: 0,
1784                    viewport_width: frame.camera.viewport_size[0].max(1.0),
1785                    viewport_height: frame.camera.viewport_size[1].max(1.0),
1786                };
1787                // Write to per-viewport slot buffer.
1788                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1789                    queue.write_buffer(
1790                        &slot.clip_planes_buf,
1791                        0,
1792                        bytemuck::cast_slice(&[clip_uniform]),
1793                    );
1794                    queue.write_buffer(
1795                        &slot.clip_volume_buf,
1796                        0,
1797                        bytemuck::cast_slice(&[clip_vols_uniform]),
1798                    );
1799                }
1800                // Also write to shared buffers for legacy single-viewport callers.
1801                queue.write_buffer(
1802                    &resources.clip_planes_uniform_buf,
1803                    0,
1804                    bytemuck::cast_slice(&[clip_uniform]),
1805                );
1806                queue.write_buffer(
1807                    &resources.clip_volume_uniform_buf,
1808                    0,
1809                    bytemuck::cast_slice(&[clip_vols_uniform]),
1810                );
1811            }
1812
1813            // Upload camera uniform to per-viewport slot buffer.
1814            let camera_uniform = frame.camera.render_camera.camera_uniform();
1815            // Write to shared buffer for legacy single-viewport callers.
1816            queue.write_buffer(
1817                &resources.camera_uniform_buf,
1818                0,
1819                bytemuck::cast_slice(&[camera_uniform]),
1820            );
1821            // Write to the per-viewport slot buffer.
1822            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1823                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1824            }
1825
1826            // Upload grid uniform (full-screen analytical shader : no vertex buffers needed).
1827            if frame.viewport.show_grid {
1828                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1829                if !eye.is_finite() {
1830                    tracing::warn!(
1831                        eye_x = eye.x,
1832                        eye_y = eye.y,
1833                        eye_z = eye.z,
1834                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1835                    );
1836                } else {
1837                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1838
1839                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1840                        (frame.viewport.grid_cell_size, 1.0_f32)
1841                    } else {
1842                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1843                        let world_per_pixel =
1844                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1845                                / frame.camera.viewport_size[1].max(1.0);
1846                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1847                        let mut s = 1.0_f32;
1848                        let mut iters = 0u32;
1849                        while s < target {
1850                            s *= 10.0;
1851                            iters += 1;
1852                        }
1853                        let ratio = (target / s).clamp(0.0, 1.0);
1854                        let fade = if ratio < 0.5 {
1855                            1.0_f32
1856                        } else {
1857                            let t = (ratio - 0.5) * 2.0;
1858                            1.0 - t * t * (3.0 - 2.0 * t)
1859                        };
1860                        tracing::debug!(
1861                            eye_z = eye.z,
1862                            vertical_depth,
1863                            world_per_pixel,
1864                            target,
1865                            spacing = s,
1866                            lod_iters = iters,
1867                            ratio,
1868                            minor_fade = fade,
1869                            "grid LOD"
1870                        );
1871                        (s, fade)
1872                    };
1873
1874                    let spacing_major = spacing * 10.0;
1875                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1876                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1877                    tracing::debug!(
1878                        spacing_minor = spacing,
1879                        spacing_major,
1880                        snap_x,
1881                        snap_y,
1882                        eye_x = eye.x,
1883                        eye_y = eye.y,
1884                        eye_z = eye.z,
1885                        "grid snap"
1886                    );
1887
1888                    let orient = frame.camera.render_camera.orientation;
1889                    let right = orient * glam::Vec3::X;
1890                    let up = orient * glam::Vec3::Y;
1891                    let back = orient * glam::Vec3::Z;
1892                    let cam_to_world = [
1893                        [right.x, right.y, right.z, 0.0_f32],
1894                        [up.x, up.y, up.z, 0.0_f32],
1895                        [back.x, back.y, back.z, 0.0_f32],
1896                    ];
1897                    let aspect =
1898                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1899                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1900
1901                    let uniform = GridUniform {
1902                        view_proj: view_proj_mat,
1903                        cam_to_world,
1904                        tan_half_fov,
1905                        aspect,
1906                        _pad_ivp: [0.0; 2],
1907                        eye_pos: frame.camera.render_camera.eye_position,
1908                        grid_z: frame.viewport.grid_z,
1909                        spacing_minor: spacing,
1910                        spacing_major,
1911                        snap_origin: [snap_x, snap_y],
1912                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1913                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1914                    };
1915                    // Write to per-viewport slot buffer.
1916                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1917                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1918                    }
1919                    // Also write to shared buffer for legacy callers.
1920                    queue.write_buffer(
1921                        &resources.grid_uniform_buf,
1922                        0,
1923                        bytemuck::cast_slice(&[uniform]),
1924                    );
1925                }
1926            }
1927            // ------------------------------------------------------------------
1928            // Ground plane uniform upload.
1929            // ------------------------------------------------------------------
1930            {
1931                let gp = &viewport_fx.ground_plane;
1932                let mode_u32: u32 = match gp.mode {
1933                    crate::renderer::types::GroundPlaneMode::None => 0,
1934                    crate::renderer::types::GroundPlaneMode::ShadowOnly => 1,
1935                    crate::renderer::types::GroundPlaneMode::Tile => 2,
1936                    crate::renderer::types::GroundPlaneMode::SolidColor => 3,
1937                };
1938                let orient = frame.camera.render_camera.orientation;
1939                let right = orient * glam::Vec3::X;
1940                let up = orient * glam::Vec3::Y;
1941                let back = orient * glam::Vec3::Z;
1942                let aspect = frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1943                let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1944                let vp = frame.camera.render_camera.view_proj().to_cols_array_2d();
1945                let gp_uniform = crate::resources::GroundPlaneUniform {
1946                    view_proj: vp,
1947                    cam_right: [right.x, right.y, right.z, 0.0],
1948                    cam_up: [up.x, up.y, up.z, 0.0],
1949                    cam_back: [back.x, back.y, back.z, 0.0],
1950                    eye_pos: frame.camera.render_camera.eye_position,
1951                    height: gp.height,
1952                    color: gp.color,
1953                    shadow_color: gp.shadow_color,
1954                    light_vp: gp_cascade0_mat,
1955                    tan_half_fov,
1956                    aspect,
1957                    tile_size: gp.tile_size,
1958                    shadow_bias: 0.002,
1959                    mode: mode_u32,
1960                    shadow_opacity: gp.shadow_opacity,
1961                    _pad: [0.0; 2],
1962                };
1963                queue.write_buffer(
1964                    &resources.ground_plane_uniform_buf,
1965                    0,
1966                    bytemuck::cast_slice(&[gp_uniform]),
1967                );
1968            }
1969        } // `resources` mutable borrow dropped here.
1970
1971        // ------------------------------------------------------------------
1972        // Build per-viewport interaction state into local variables.
1973        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1974        // the slot borrow that follows.
1975        // ------------------------------------------------------------------
1976
1977        let vp_idx = frame.camera.viewport_index;
1978
1979        // Outline mask buffers for selected objects (one per selected object).
1980        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1981        if frame.interaction.outline_selected {
1982            let resources = &self.resources;
1983            for item in scene_items {
1984                if !item.visible || !item.selected {
1985                    continue;
1986                }
1987                let uniform = OutlineUniform {
1988                    model: item.model,
1989                    color: [0.0; 4], // unused by mask shader
1990                    pixel_offset: 0.0,
1991                    _pad: [0.0; 3],
1992                };
1993                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1994                    label: Some("outline_mask_uniform_buf"),
1995                    size: std::mem::size_of::<OutlineUniform>() as u64,
1996                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1997                    mapped_at_creation: false,
1998                });
1999                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
2000                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2001                    label: Some("outline_mask_object_bg"),
2002                    layout: &resources.outline_bind_group_layout,
2003                    entries: &[wgpu::BindGroupEntry {
2004                        binding: 0,
2005                        resource: buf.as_entire_binding(),
2006                    }],
2007                });
2008                outline_object_buffers.push(OutlineObjectBuffers {
2009                    mesh_id: item.mesh_id,
2010                    two_sided: item.material.is_two_sided(),
2011                    _mask_uniform_buf: buf,
2012                    mask_bind_group: bg,
2013                });
2014            }
2015        }
2016
2017        // Splat outline buffers: point sprite discs for selected Gaussian splat sets.
2018        let mut splat_outline_buffers: Vec<crate::resources::SplatOutlineBuffers> = Vec::new();
2019        // Each entry is (gpu_data_index, instance_ranges).
2020        // None = draw all instances (object-level selection).
2021        // Some(vec) = draw only these specific instance indices (sub-object Instance selection).
2022        let mut glyph_outline_indices: Vec<(usize, Option<Vec<u32>>)> = Vec::new();
2023        let mut tensor_glyph_outline_indices: Vec<(usize, Option<Vec<u32>>)> = Vec::new();
2024        let mut sprite_outline_indices: Vec<(usize, Option<Vec<u32>>)> = Vec::new();
2025        if frame.interaction.outline_selected {
2026            let resources = &self.resources;
2027            let view_proj = frame.camera.render_camera.view_proj();
2028            let [vp_w, vp_h] = frame.camera.viewport_size;
2029            for item in &frame.scene.gaussian_splats {
2030                let Some(gpu_set) = resources.gaussian_splat_store.get(item.id.0) else {
2031                    continue;
2032                };
2033                if item.selected && !gpu_set.cpu_positions.is_empty() {
2034                    // Object-level: outline all splats.
2035                    // World-space radius covering the visible Gaussian tail (~3 sigma).
2036                    let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
2037                        0.05
2038                    } else {
2039                        gpu_set.cpu_scales.iter()
2040                            .map(|s| s[0].max(s[1]).max(s[2]))
2041                            .sum::<f32>()
2042                            / gpu_set.cpu_scales.len() as f32
2043                    };
2044                    let world_radius = mean_max_scale * 3.0;
2045
2046                    // Project the world radius to a pixel half-size at the cloud center.
2047                    // Use the camera right vector so the offset is always perpendicular
2048                    // to the view direction, avoiding the collapse when looking along X.
2049                    let model = glam::Mat4::from_cols_array_2d(&item.model);
2050                    let center_w = model.transform_point3(glam::Vec3::ZERO);
2051                    let cam_right = frame.camera.render_camera.view.row(0).truncate().normalize();
2052                    let p0_clip = view_proj * glam::Vec4::new(center_w.x, center_w.y, center_w.z, 1.0);
2053                    let p1_world = center_w + cam_right * world_radius;
2054                    let p1_clip = view_proj * glam::Vec4::new(p1_world.x, p1_world.y, p1_world.z, 1.0);
2055                    let pixel_radius = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
2056                        let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
2057                        let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
2058                        (p1_ndc - p0_ndc).length() * 0.5 * vp_w.max(vp_h)
2059                    } else {
2060                        world_radius * 100.0
2061                    };
2062                    let pixel_radius = pixel_radius.max(1.0);
2063
2064                    let position_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2065                        label: Some("splat_outline_pos_buf"),
2066                        contents: bytemuck::cast_slice(gpu_set.cpu_positions.as_slice()),
2067                        usage: wgpu::BufferUsages::VERTEX,
2068                    });
2069
2070                    let uniform = SplatOutlineMaskUniform {
2071                        model: item.model,
2072                        viewport_w: vp_w,
2073                        viewport_h: vp_h,
2074                        pixel_radius,
2075                        _pad: [0.0; 5],
2076                    };
2077                    let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2078                        label: Some("splat_outline_uniform_buf"),
2079                        contents: bytemuck::cast_slice(&[uniform]),
2080                        usage: wgpu::BufferUsages::UNIFORM,
2081                    });
2082                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2083                        label: Some("splat_outline_bg"),
2084                        layout: &resources.outline_bind_group_layout,
2085                        entries: &[wgpu::BindGroupEntry {
2086                            binding: 0,
2087                            resource: uniform_buf.as_entire_binding(),
2088                        }],
2089                    });
2090
2091                    let n = gpu_set.cpu_positions.len();
2092                    let size_data: Vec<f32> = vec![pixel_radius; n];
2093                    let size_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2094                        label: Some("splat_outline_size_buf"),
2095                        contents: bytemuck::cast_slice(&size_data),
2096                        usage: wgpu::BufferUsages::VERTEX,
2097                    });
2098
2099                    splat_outline_buffers.push(crate::resources::SplatOutlineBuffers {
2100                        position_buf,
2101                        size_buf,
2102                        instance_count: n as u32,
2103                        _uniform_buf: uniform_buf,
2104                        bind_group,
2105                    });
2106                } else if !item.selected && item.pick_id != 0 {
2107                    // Per-splat sub-selection: outline only the selected splats.
2108                    let sub_sel = frame.interaction.sub_selection.as_ref();
2109                    let selected_indices: Vec<u32> = sub_sel
2110                        .iter()
2111                        .flat_map(|s| s.items.iter())
2112                        .filter_map(|(node_id, sub)| {
2113                            if *node_id == item.pick_id {
2114                                if let crate::interaction::sub_object::SubObjectRef::Splat(i) = sub {
2115                                    return Some(*i);
2116                                }
2117                            }
2118                            None
2119                        })
2120                        .collect();
2121                    if selected_indices.is_empty() {
2122                        continue;
2123                    }
2124
2125                    let model = glam::Mat4::from_cols_array_2d(&item.model);
2126                    let cam_right = frame.camera.render_camera.view.row(0).truncate().normalize();
2127
2128                    let mut positions: Vec<[f32; 3]> = Vec::with_capacity(selected_indices.len());
2129                    let mut sizes: Vec<f32> = Vec::with_capacity(selected_indices.len());
2130                    for &idx in &selected_indices {
2131                        let i = idx as usize;
2132                        if let Some(&pos) = gpu_set.cpu_positions.get(i) {
2133                            positions.push(pos);
2134                            let world_radius = if let Some(s) = gpu_set.cpu_scales.get(i) {
2135                                s[0].max(s[1]).max(s[2]) * 3.0
2136                            } else {
2137                                0.15
2138                            };
2139                            let center_w = model.transform_point3(glam::Vec3::from(pos));
2140                            let p0_clip = view_proj * glam::Vec4::new(center_w.x, center_w.y, center_w.z, 1.0);
2141                            let p1_world = center_w + cam_right * world_radius;
2142                            let p1_clip = view_proj * glam::Vec4::new(p1_world.x, p1_world.y, p1_world.z, 1.0);
2143                            let px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
2144                                let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
2145                                let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
2146                                ((p1_ndc - p0_ndc).length() * 0.5 * vp_w.max(vp_h)).max(1.0)
2147                            } else {
2148                                world_radius * 100.0
2149                            };
2150                            sizes.push(px);
2151                        }
2152                    }
2153                    if positions.is_empty() {
2154                        continue;
2155                    }
2156
2157                    let pixel_radius = sizes.iter().cloned().fold(f32::NEG_INFINITY, f32::max).max(1.0);
2158                    let uniform = SplatOutlineMaskUniform {
2159                        model: item.model,
2160                        viewport_w: vp_w,
2161                        viewport_h: vp_h,
2162                        pixel_radius,
2163                        _pad: [0.0; 5],
2164                    };
2165                    let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2166                        label: Some("splat_sel_outline_uniform_buf"),
2167                        contents: bytemuck::cast_slice(&[uniform]),
2168                        usage: wgpu::BufferUsages::UNIFORM,
2169                    });
2170                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2171                        label: Some("splat_sel_outline_bg"),
2172                        layout: &resources.outline_bind_group_layout,
2173                        entries: &[wgpu::BindGroupEntry {
2174                            binding: 0,
2175                            resource: uniform_buf.as_entire_binding(),
2176                        }],
2177                    });
2178                    let position_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2179                        label: Some("splat_sel_outline_pos_buf"),
2180                        contents: bytemuck::cast_slice(&positions),
2181                        usage: wgpu::BufferUsages::VERTEX,
2182                    });
2183                    let size_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2184                        label: Some("splat_sel_outline_size_buf"),
2185                        contents: bytemuck::cast_slice(&sizes),
2186                        usage: wgpu::BufferUsages::VERTEX,
2187                    });
2188                    splat_outline_buffers.push(crate::resources::SplatOutlineBuffers {
2189                        position_buf,
2190                        size_buf,
2191                        instance_count: positions.len() as u32,
2192                        _uniform_buf: uniform_buf,
2193                        bind_group,
2194                    });
2195                }
2196            }
2197
2198            // Point cloud outline buffers: reuse the same point sprite mask pipeline.
2199            for item in &frame.scene.point_clouds {
2200                if item.positions.is_empty() {
2201                    continue;
2202                }
2203                let pixel_radius = (item.point_size * 0.5).max(1.0);
2204                if item.selected {
2205                    // Object-level: outline all points.
2206                    let position_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2207                        label: Some("pc_outline_pos_buf"),
2208                        contents: bytemuck::cast_slice(item.positions.as_slice()),
2209                        usage: wgpu::BufferUsages::VERTEX,
2210                    });
2211                    let uniform = SplatOutlineMaskUniform {
2212                        model: item.model,
2213                        viewport_w: vp_w,
2214                        viewport_h: vp_h,
2215                        pixel_radius,
2216                        _pad: [0.0; 5],
2217                    };
2218                    let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2219                        label: Some("pc_outline_uniform_buf"),
2220                        contents: bytemuck::cast_slice(&[uniform]),
2221                        usage: wgpu::BufferUsages::UNIFORM,
2222                    });
2223                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2224                        label: Some("pc_outline_bg"),
2225                        layout: &self.resources.outline_bind_group_layout,
2226                        entries: &[wgpu::BindGroupEntry {
2227                            binding: 0,
2228                            resource: uniform_buf.as_entire_binding(),
2229                        }],
2230                    });
2231                    let n = item.positions.len();
2232                    let size_data: Vec<f32> = vec![pixel_radius; n];
2233                    let size_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2234                        label: Some("pc_outline_size_buf"),
2235                        contents: bytemuck::cast_slice(&size_data),
2236                        usage: wgpu::BufferUsages::VERTEX,
2237                    });
2238                    splat_outline_buffers.push(crate::resources::SplatOutlineBuffers {
2239                        position_buf,
2240                        size_buf,
2241                        instance_count: n as u32,
2242                        _uniform_buf: uniform_buf,
2243                        bind_group,
2244                    });
2245                } else if item.id != 0 {
2246                    // Per-point sub-selection: outline only the selected points.
2247                    let sub_sel = frame.interaction.sub_selection.as_ref();
2248                    let selected_positions: Vec<[f32; 3]> = sub_sel
2249                        .iter()
2250                        .flat_map(|s| s.items.iter())
2251                        .filter_map(|(node_id, sub)| {
2252                            if *node_id == item.id {
2253                                if let crate::interaction::sub_object::SubObjectRef::Point(i) = sub {
2254                                    return item.positions.get(*i as usize).copied();
2255                                }
2256                            }
2257                            None
2258                        })
2259                        .collect();
2260                    if selected_positions.is_empty() {
2261                        continue;
2262                    }
2263                    let n = selected_positions.len();
2264                    let uniform = SplatOutlineMaskUniform {
2265                        model: item.model,
2266                        viewport_w: vp_w,
2267                        viewport_h: vp_h,
2268                        pixel_radius,
2269                        _pad: [0.0; 5],
2270                    };
2271                    let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2272                        label: Some("pc_sel_outline_uniform_buf"),
2273                        contents: bytemuck::cast_slice(&[uniform]),
2274                        usage: wgpu::BufferUsages::UNIFORM,
2275                    });
2276                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2277                        label: Some("pc_sel_outline_bg"),
2278                        layout: &self.resources.outline_bind_group_layout,
2279                        entries: &[wgpu::BindGroupEntry {
2280                            binding: 0,
2281                            resource: uniform_buf.as_entire_binding(),
2282                        }],
2283                    });
2284                    let position_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2285                        label: Some("pc_sel_outline_pos_buf"),
2286                        contents: bytemuck::cast_slice(&selected_positions),
2287                        usage: wgpu::BufferUsages::VERTEX,
2288                    });
2289                    let size_data = vec![pixel_radius; n];
2290                    let size_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2291                        label: Some("pc_sel_outline_size_buf"),
2292                        contents: bytemuck::cast_slice(&size_data),
2293                        usage: wgpu::BufferUsages::VERTEX,
2294                    });
2295                    splat_outline_buffers.push(crate::resources::SplatOutlineBuffers {
2296                        position_buf,
2297                        size_buf,
2298                        instance_count: n as u32,
2299                        _uniform_buf: uniform_buf,
2300                        bind_group,
2301                    });
2302                }
2303            }
2304
2305            // Glyph outline indices: record which glyph GPU data entries are selected
2306            // so the mask pass can render the actual instanced mesh.
2307            {
2308                let sub_sel = frame.interaction.sub_selection.as_ref();
2309                let mut gpu_idx = 0usize;
2310                for item in &frame.scene.glyphs {
2311                    if item.positions.is_empty() || item.vectors.is_empty() {
2312                        continue;
2313                    }
2314                    if item.selected {
2315                        self.resources.ensure_glyph_outline_mask_pipeline(device);
2316                        glyph_outline_indices.push((gpu_idx, None));
2317                    } else if item.id != 0 {
2318                        // Check for per-instance sub-selection.
2319                        let instances: Vec<u32> = sub_sel
2320                            .iter()
2321                            .flat_map(|s| s.items.iter())
2322                            .filter_map(|(node_id, sub)| {
2323                                if *node_id == item.id {
2324                                    if let crate::interaction::sub_object::SubObjectRef::Instance(i) = sub {
2325                                        return Some(*i);
2326                                    }
2327                                }
2328                                None
2329                            })
2330                            .collect();
2331                        if !instances.is_empty() {
2332                            self.resources.ensure_glyph_outline_mask_pipeline(device);
2333                            glyph_outline_indices.push((gpu_idx, Some(instances)));
2334                        }
2335                    }
2336                    gpu_idx += 1;
2337                }
2338            }
2339
2340            // Polyline outline buffers: point sprite discs at node positions.
2341            for item in &frame.scene.polylines {
2342                if !item.selected || item.positions.is_empty() {
2343                    continue;
2344                }
2345
2346                let pixel_radius = item.line_width.max(2.0);
2347
2348                let position_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2349                    label: Some("polyline_outline_pos_buf"),
2350                    contents: bytemuck::cast_slice(item.positions.as_slice()),
2351                    usage: wgpu::BufferUsages::VERTEX,
2352                });
2353
2354                let uniform = SplatOutlineMaskUniform {
2355                    model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2356                    viewport_w: vp_w,
2357                    viewport_h: vp_h,
2358                    pixel_radius,
2359                    _pad: [0.0; 5],
2360                };
2361                let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2362                    label: Some("polyline_outline_uniform_buf"),
2363                    contents: bytemuck::cast_slice(&[uniform]),
2364                    usage: wgpu::BufferUsages::UNIFORM,
2365                });
2366                let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2367                    label: Some("polyline_outline_bg"),
2368                    layout: &self.resources.outline_bind_group_layout,
2369                    entries: &[wgpu::BindGroupEntry {
2370                        binding: 0,
2371                        resource: uniform_buf.as_entire_binding(),
2372                    }],
2373                });
2374
2375                let n = item.positions.len();
2376                let size_data: Vec<f32> = vec![pixel_radius; n];
2377                let size_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2378                    label: Some("polyline_outline_size_buf"),
2379                    contents: bytemuck::cast_slice(&size_data),
2380                    usage: wgpu::BufferUsages::VERTEX,
2381                });
2382
2383                splat_outline_buffers.push(crate::resources::SplatOutlineBuffers {
2384                    position_buf,
2385                    size_buf,
2386                    instance_count: n as u32,
2387                    _uniform_buf: uniform_buf,
2388                    bind_group,
2389                });
2390            }
2391
2392            // Sprite outline indices: record which sprite GPU data entries are selected
2393            // so the mask pass can render the actual billboard quads.
2394            {
2395                let sub_sel = frame.interaction.sub_selection.as_ref();
2396                for (i, item) in frame.scene.sprite_items.iter().enumerate() {
2397                    if item.positions.is_empty() {
2398                        continue;
2399                    }
2400                    if item.selected {
2401                        self.resources.ensure_sprite_outline_mask_pipeline(device);
2402                        sprite_outline_indices.push((i, None));
2403                    } else if item.id != 0 {
2404                        let instances: Vec<u32> = sub_sel
2405                            .iter()
2406                            .flat_map(|s| s.items.iter())
2407                            .filter_map(|(node_id, sub)| {
2408                                if *node_id == item.id {
2409                                    if let crate::interaction::sub_object::SubObjectRef::Instance(idx) = sub {
2410                                        return Some(*idx);
2411                                    }
2412                                }
2413                                None
2414                            })
2415                            .collect();
2416                        if !instances.is_empty() {
2417                            self.resources.ensure_sprite_outline_mask_pipeline(device);
2418                            sprite_outline_indices.push((i, Some(instances)));
2419                        }
2420                    }
2421                }
2422            }
2423
2424            // Streamtube / Tube / Ribbon outline buffers: point sprite discs at
2425            // control point positions.
2426            let curve_sets: Vec<(&[[f32; 3]], f32)> = frame
2427                .scene
2428                .streamtube_items
2429                .iter()
2430                .filter(|s| s.selected && !s.positions.is_empty())
2431                .map(|s| (s.positions.as_slice(), s.radius * 16.0))
2432                .chain(
2433                    frame
2434                        .scene
2435                        .tube_items
2436                        .iter()
2437                        .filter(|s| s.selected && !s.positions.is_empty())
2438                        .map(|s| (s.positions.as_slice(), s.radius * 16.0)),
2439                )
2440                .chain(
2441                    frame
2442                        .scene
2443                        .ribbon_items
2444                        .iter()
2445                        .filter(|s| s.selected && !s.positions.is_empty())
2446                        .map(|s| (s.positions.as_slice(), s.width * 8.0)),
2447                )
2448                .collect();
2449
2450            for (positions, pixel_radius) in curve_sets {
2451                let pixel_radius = pixel_radius.max(4.0);
2452
2453                let position_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2454                    label: Some("curve_outline_pos_buf"),
2455                    contents: bytemuck::cast_slice(positions),
2456                    usage: wgpu::BufferUsages::VERTEX,
2457                });
2458
2459                let uniform = SplatOutlineMaskUniform {
2460                    model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2461                    viewport_w: vp_w,
2462                    viewport_h: vp_h,
2463                    pixel_radius,
2464                    _pad: [0.0; 5],
2465                };
2466                let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2467                    label: Some("curve_outline_uniform_buf"),
2468                    contents: bytemuck::cast_slice(&[uniform]),
2469                    usage: wgpu::BufferUsages::UNIFORM,
2470                });
2471                let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2472                    label: Some("curve_outline_bg"),
2473                    layout: &self.resources.outline_bind_group_layout,
2474                    entries: &[wgpu::BindGroupEntry {
2475                        binding: 0,
2476                        resource: uniform_buf.as_entire_binding(),
2477                    }],
2478                });
2479
2480                let n = positions.len();
2481                let size_data: Vec<f32> = vec![pixel_radius; n];
2482                let size_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2483                    label: Some("curve_outline_size_buf"),
2484                    contents: bytemuck::cast_slice(&size_data),
2485                    usage: wgpu::BufferUsages::VERTEX,
2486                });
2487
2488                splat_outline_buffers.push(crate::resources::SplatOutlineBuffers {
2489                    position_buf,
2490                    size_buf,
2491                    instance_count: n as u32,
2492                    _uniform_buf: uniform_buf,
2493                    bind_group,
2494                });
2495            }
2496
2497            // Tensor glyph outline indices: same approach as arrow glyphs.
2498            {
2499                let sub_sel = frame.interaction.sub_selection.as_ref();
2500                let mut gpu_idx = 0usize;
2501                for item in &frame.scene.tensor_glyphs {
2502                    if item.positions.is_empty() {
2503                        continue;
2504                    }
2505                    if item.selected {
2506                        self.resources.ensure_tensor_glyph_outline_mask_pipeline(device);
2507                        tensor_glyph_outline_indices.push((gpu_idx, None));
2508                    } else if item.id != 0 {
2509                        let instances: Vec<u32> = sub_sel
2510                            .iter()
2511                            .flat_map(|s| s.items.iter())
2512                            .filter_map(|(node_id, sub)| {
2513                                if *node_id == item.id {
2514                                    if let crate::interaction::sub_object::SubObjectRef::Instance(i) = sub {
2515                                        return Some(*i);
2516                                    }
2517                                }
2518                                None
2519                            })
2520                            .collect();
2521                        if !instances.is_empty() {
2522                            self.resources.ensure_tensor_glyph_outline_mask_pipeline(device);
2523                            tensor_glyph_outline_indices.push((gpu_idx, Some(instances)));
2524                        }
2525                    }
2526                    gpu_idx += 1;
2527                }
2528            }
2529        }
2530
2531        // Volume outline: record indices of selected volumes so the mask pass can
2532        // reuse their VolumeGpuData bind groups (which already contain model, 3D
2533        // texture, samplers, and LUTs needed by the ray-march mask shader).
2534        let mut volume_outline_indices: Vec<usize> = Vec::new();
2535        if frame.interaction.outline_selected {
2536            self.resources.ensure_volume_cube(device);
2537            self.resources.ensure_volume_pipeline(device);
2538            self.resources.ensure_volume_outline_mask_pipeline(device);
2539            for (i, item) in frame.scene.volumes.iter().enumerate() {
2540                if item.selected {
2541                    volume_outline_indices.push(i);
2542                }
2543            }
2544        }
2545
2546        // X-ray buffers for selected objects.
2547        let mut xray_object_buffers: Vec<(crate::resources::mesh_store::MeshId, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
2548        if frame.interaction.xray_selected {
2549            let resources = &self.resources;
2550            for item in scene_items {
2551                if !item.visible || !item.selected {
2552                    continue;
2553                }
2554                let uniform = OutlineUniform {
2555                    model: item.model,
2556                    color: frame.interaction.xray_color,
2557                    pixel_offset: 0.0,
2558                    _pad: [0.0; 3],
2559                };
2560                let buf = device.create_buffer(&wgpu::BufferDescriptor {
2561                    label: Some("xray_uniform_buf"),
2562                    size: std::mem::size_of::<OutlineUniform>() as u64,
2563                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2564                    mapped_at_creation: false,
2565                });
2566                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
2567                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2568                    label: Some("xray_object_bg"),
2569                    layout: &resources.outline_bind_group_layout,
2570                    entries: &[wgpu::BindGroupEntry {
2571                        binding: 0,
2572                        resource: buf.as_entire_binding(),
2573                    }],
2574                });
2575                xray_object_buffers.push((item.mesh_id, buf, bg));
2576            }
2577        }
2578
2579        // Constraint guide lines.
2580        let mut constraint_line_buffers = Vec::new();
2581        for overlay in &frame.interaction.constraint_overlays {
2582            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
2583        }
2584
2585        // Clip plane overlays : generated automatically from clip_objects with a color set.
2586        let mut clip_plane_fill_buffers = Vec::new();
2587        let mut clip_plane_line_buffers = Vec::new();
2588        for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
2589            // Skip if neither fill nor edge color is set.
2590            if obj.color.is_none() && obj.edge_color.is_none() {
2591                continue;
2592            }
2593            if let ClipShape::Plane {
2594                normal,
2595                distance,
2596                display_center,
2597                ..
2598            } = obj.shape
2599            {
2600                let n = glam::Vec3::from(normal);
2601                // Use the caller-supplied display_center when available so that
2602                // lateral translations (tangent to the plane) are reflected in
2603                // the overlay quad position.  Fall back to the foot-of-normal
2604                // from the world origin when none is set.
2605                let center = display_center
2606                    .map(glam::Vec3::from)
2607                    .unwrap_or_else(|| n * (-distance));
2608                let active = obj.active;
2609                let hovered = obj.hovered || active;
2610
2611                // Fill quad: derived from `color`; transparent if not set.
2612                let fill_color = if let Some(base_color) = obj.color {
2613                    if active {
2614                        [
2615                            base_color[0] * 0.5,
2616                            base_color[1] * 0.5,
2617                            base_color[2] * 0.5,
2618                            base_color[3] * 0.5,
2619                        ]
2620                    } else if hovered {
2621                        [
2622                            base_color[0] * 0.8,
2623                            base_color[1] * 0.8,
2624                            base_color[2] * 0.8,
2625                            base_color[3] * 0.6,
2626                        ]
2627                    } else {
2628                        [
2629                            base_color[0] * 0.5,
2630                            base_color[1] * 0.5,
2631                            base_color[2] * 0.5,
2632                            base_color[3] * 0.3,
2633                        ]
2634                    }
2635                } else {
2636                    [0.0, 0.0, 0.0, 0.0]
2637                };
2638
2639                // Border edge: use `edge_color` when set, otherwise derive from `color`.
2640                let border_base = obj
2641                    .edge_color
2642                    .or(obj.color)
2643                    .unwrap_or([1.0, 1.0, 1.0, 1.0]);
2644                let border_color = if active {
2645                    [border_base[0], border_base[1], border_base[2], 0.9]
2646                } else if hovered {
2647                    [border_base[0], border_base[1], border_base[2], 0.8]
2648                } else {
2649                    [
2650                        border_base[0] * 0.9,
2651                        border_base[1] * 0.9,
2652                        border_base[2] * 0.9,
2653                        0.6,
2654                    ]
2655                };
2656
2657                let overlay = crate::interaction::clip_plane::ClipPlaneOverlay {
2658                    center,
2659                    normal: n,
2660                    extent: obj.extent,
2661                    fill_color,
2662                    border_color,
2663                    _hovered: hovered,
2664                    _active: active,
2665                };
2666                if obj.color.is_some() {
2667                    clip_plane_fill_buffers.push(
2668                        self.resources
2669                            .create_clip_plane_fill_overlay(device, &overlay),
2670                    );
2671                }
2672                clip_plane_line_buffers.push(
2673                    self.resources
2674                        .create_clip_plane_line_overlay(device, &overlay),
2675                );
2676            } else {
2677                // Box/Sphere/Cylinder: generate wireframe polyline overlay.
2678                // These use the clip-exempt pipeline so the outline is always fully visible,
2679                // even when multiple clip volumes are active (the user needs to see where each
2680                // clip is positioned to understand the combined result).
2681                let base_color = obj.color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
2682                self.resources.ensure_polyline_no_clip_pipeline(device);
2683                match obj.shape {
2684                    ClipShape::Box {
2685                        center,
2686                        half_extents,
2687                        orientation,
2688                    } => {
2689                        let polyline =
2690                            clip_box_outline(center, half_extents, orientation, base_color);
2691                        let vp_size = frame.camera.viewport_size;
2692                        let mut gpu = self
2693                            .resources
2694                            .upload_polyline(device, queue, &polyline, vp_size);
2695                        gpu.skip_clip = true;
2696                        self.polyline_gpu_data.push(gpu);
2697                    }
2698                    ClipShape::Sphere { center, radius } => {
2699                        let polyline = clip_sphere_outline(center, radius, base_color);
2700                        let vp_size = frame.camera.viewport_size;
2701                        let mut gpu = self
2702                            .resources
2703                            .upload_polyline(device, queue, &polyline, vp_size);
2704                        gpu.skip_clip = true;
2705                        self.polyline_gpu_data.push(gpu);
2706                    }
2707                    ClipShape::Cylinder {
2708                        center,
2709                        axis,
2710                        radius,
2711                        half_length,
2712                    } => {
2713                        let polyline =
2714                            clip_cylinder_outline(center, axis, radius, half_length, base_color);
2715                        let vp_size = frame.camera.viewport_size;
2716                        let mut gpu = self
2717                            .resources
2718                            .upload_polyline(device, queue, &polyline, vp_size);
2719                        gpu.skip_clip = true;
2720                        self.polyline_gpu_data.push(gpu);
2721                    }
2722                    _ => {}
2723                }
2724            }
2725        }
2726
2727        // Cap geometry for section-view cross-section fill.
2728        let mut cap_buffers = Vec::new();
2729        if viewport_fx.cap_fill_enabled {
2730            for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
2731                if let ClipShape::Plane {
2732                    normal,
2733                    distance,
2734                    cap_color,
2735                    ..
2736                } = obj.shape
2737                {
2738                    let plane_n = glam::Vec3::from(normal);
2739                    for item in scene_items.iter().filter(|i| i.visible) {
2740                        let Some(mesh) = self
2741                            .resources
2742                            .mesh_store
2743                            .get(item.mesh_id)
2744                        else {
2745                            continue;
2746                        };
2747                        let model = glam::Mat4::from_cols_array_2d(&item.model);
2748                        let world_aabb = mesh.aabb.transformed(&model);
2749                        if !world_aabb.intersects_plane(plane_n, distance) {
2750                            continue;
2751                        }
2752                        let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices)
2753                        else {
2754                            continue;
2755                        };
2756                        if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
2757                            pos, idx, &model, plane_n, distance,
2758                        ) {
2759                            let bc = item.material.base_color;
2760                            let color = cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
2761                            let buf = self.resources.upload_cap_geometry(device, &cap, color);
2762                            cap_buffers.push(buf);
2763                        }
2764                    }
2765                }
2766            }
2767        }
2768
2769        // Axes indicator geometry (built here, written to slot buffer below).
2770        let axes_verts = if frame.viewport.show_axes_indicator
2771            && frame.camera.viewport_size[0] > 0.0
2772            && frame.camera.viewport_size[1] > 0.0
2773        {
2774            let verts = crate::widgets::axes_indicator::build_axes_geometry(
2775                frame.camera.viewport_size[0],
2776                frame.camera.viewport_size[1],
2777                frame.camera.render_camera.orientation,
2778            );
2779            if verts.is_empty() { None } else { Some(verts) }
2780        } else {
2781            None
2782        };
2783
2784        // Gizmo mesh + uniform (built here, written to slot buffers below).
2785        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
2786            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
2787                frame.interaction.gizmo_mode,
2788                frame.interaction.gizmo_hovered,
2789                frame.interaction.gizmo_space_orientation,
2790            );
2791            (verts, indices, model)
2792        });
2793
2794        // ------------------------------------------------------------------
2795        // Assign all interaction state to the per-viewport slot.
2796        // ------------------------------------------------------------------
2797        {
2798            let slot = &mut self.viewport_slots[vp_idx];
2799            slot.outline_object_buffers = outline_object_buffers;
2800            slot.splat_outline_buffers = splat_outline_buffers;
2801            slot.volume_outline_indices = volume_outline_indices;
2802            slot.glyph_outline_indices = glyph_outline_indices;
2803            slot.tensor_glyph_outline_indices = tensor_glyph_outline_indices;
2804            slot.sprite_outline_indices = sprite_outline_indices;
2805            slot.xray_object_buffers = xray_object_buffers;
2806            slot.constraint_line_buffers = constraint_line_buffers;
2807            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
2808            slot.clip_plane_line_buffers = clip_plane_line_buffers;
2809            slot.cap_buffers = cap_buffers;
2810
2811            // Axes: resize buffer if needed, then upload.
2812            if let Some(verts) = axes_verts {
2813                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
2814                if byte_size > slot.axes_vertex_buffer.size() {
2815                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2816                        label: Some("vp_axes_vertex_buf"),
2817                        size: byte_size,
2818                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2819                        mapped_at_creation: false,
2820                    });
2821                }
2822                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
2823                slot.axes_vertex_count = verts.len() as u32;
2824            } else {
2825                slot.axes_vertex_count = 0;
2826            }
2827
2828            // Gizmo: resize buffers if needed, then upload mesh + uniform.
2829            if let Some((verts, indices, model)) = gizmo_update {
2830                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
2831                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
2832                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
2833                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2834                        label: Some("vp_gizmo_vertex_buf"),
2835                        size: vert_bytes.len() as u64,
2836                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2837                        mapped_at_creation: false,
2838                    });
2839                }
2840                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
2841                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2842                        label: Some("vp_gizmo_index_buf"),
2843                        size: idx_bytes.len() as u64,
2844                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
2845                        mapped_at_creation: false,
2846                    });
2847                }
2848                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
2849                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
2850                slot.gizmo_index_count = indices.len() as u32;
2851                let uniform = crate::interaction::gizmo::GizmoUniform {
2852                    model: model.to_cols_array_2d(),
2853                };
2854                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
2855            }
2856        }
2857
2858        // ------------------------------------------------------------------
2859        // Outline offscreen pass : screen-space edge detection.
2860        //
2861        // 1. Render selected objects to an R8 mask texture (white on black).
2862        // 2. Run a fullscreen edge-detection pass reading the mask and writing
2863        //    an anti-aliased outline ring to the outline color texture.
2864        //
2865        // The outline color texture is later composited onto the main target
2866        // by the composite pass in paint()/render().
2867        // ------------------------------------------------------------------
2868        if frame.interaction.outline_selected
2869            && (!self.viewport_slots[vp_idx].outline_object_buffers.is_empty()
2870                || !self.viewport_slots[vp_idx].splat_outline_buffers.is_empty()
2871                || !self.viewport_slots[vp_idx].volume_outline_indices.is_empty()
2872                || !self.viewport_slots[vp_idx].glyph_outline_indices.is_empty()
2873                || !self.viewport_slots[vp_idx].tensor_glyph_outline_indices.is_empty()
2874                || !self.viewport_slots[vp_idx].sprite_outline_indices.is_empty())
2875        {
2876            let ppp = frame.camera.pixels_per_point;
2877            let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
2878            let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
2879
2880            // Ensure per-viewport HDR state exists (provides outline textures).
2881            self.ensure_viewport_hdr(
2882                device,
2883                queue,
2884                vp_idx,
2885                w.max(1),
2886                h.max(1),
2887                frame.effects.post_process.ssaa_factor.max(1),
2888            );
2889
2890            // Write edge-detection uniform (color, radius, viewport size).
2891            {
2892                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2893                let edge_uniform = OutlineEdgeUniform {
2894                    color: frame.interaction.outline_color,
2895                    radius: frame.interaction.outline_width_px,
2896                    viewport_w: w as f32,
2897                    viewport_h: h as f32,
2898                    _pad: 0.0,
2899                };
2900                queue.write_buffer(
2901                    &slot_hdr.outline_edge_uniform_buf,
2902                    0,
2903                    bytemuck::cast_slice(&[edge_uniform]),
2904                );
2905            }
2906
2907            // Extract raw pointers for slot fields needed inside the render
2908            // passes alongside &self.resources borrows.
2909            let slot_ref = &self.viewport_slots[vp_idx];
2910            let outlines_ptr =
2911                &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
2912            let splat_outlines_ptr =
2913                &slot_ref.splat_outline_buffers as *const Vec<crate::resources::SplatOutlineBuffers>;
2914            let vol_outline_idx_ptr =
2915                &slot_ref.volume_outline_indices as *const Vec<usize>;
2916            let glyph_outline_idx_ptr =
2917                &slot_ref.glyph_outline_indices as *const Vec<(usize, Option<Vec<u32>>)>;
2918            let tensor_glyph_outline_idx_ptr =
2919                &slot_ref.tensor_glyph_outline_indices as *const Vec<(usize, Option<Vec<u32>>)>;
2920            let sprite_outline_idx_ptr =
2921                &slot_ref.sprite_outline_indices as *const Vec<(usize, Option<Vec<u32>>)>;
2922            let glyph_gpu_ptr =
2923                &self.glyph_gpu_data as *const Vec<crate::resources::GlyphGpuData>;
2924            let tensor_glyph_gpu_ptr =
2925                &self.tensor_glyph_gpu_data as *const Vec<crate::resources::TensorGlyphGpuData>;
2926            let sprite_gpu_ptr =
2927                &self.sprite_gpu_data as *const Vec<crate::resources::SpriteGpuData>;
2928            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
2929            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
2930            let mask_view_ptr = &slot_hdr.outline_mask_view as *const wgpu::TextureView;
2931            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
2932            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
2933            let edge_bg_ptr = &slot_hdr.outline_edge_bind_group as *const wgpu::BindGroup;
2934            // SAFETY: slot fields remain valid for the duration of this function;
2935            // no other code modifies these fields here.
2936            let (outlines, splat_outlines, vol_outline_indices,
2937                 glyph_outline_indices, tensor_glyph_outline_indices,
2938                 sprite_outline_indices,
2939                 glyph_gpu_data, tensor_glyph_gpu_data, sprite_gpu_data,
2940                 camera_bg, mask_view, color_view, depth_view, edge_bg) = unsafe {
2941                (
2942                    &*outlines_ptr,
2943                    &*splat_outlines_ptr,
2944                    &*vol_outline_idx_ptr,
2945                    &*glyph_outline_idx_ptr,
2946                    &*tensor_glyph_outline_idx_ptr,
2947                    &*sprite_outline_idx_ptr,
2948                    &*glyph_gpu_ptr,
2949                    &*tensor_glyph_gpu_ptr,
2950                    &*sprite_gpu_ptr,
2951                    &*camera_bg_ptr,
2952                    &*mask_view_ptr,
2953                    &*color_view_ptr,
2954                    &*depth_view_ptr,
2955                    &*edge_bg_ptr,
2956                )
2957            };
2958
2959            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2960                label: Some("outline_offscreen_encoder"),
2961            });
2962
2963            // Pass 1: render selected objects to R8 mask texture.
2964            {
2965                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2966                    label: Some("outline_mask_pass"),
2967                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2968                        view: mask_view,
2969                        resolve_target: None,
2970                        ops: wgpu::Operations {
2971                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2972                            store: wgpu::StoreOp::Store,
2973                        },
2974                        depth_slice: None,
2975                    })],
2976                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2977                        view: depth_view,
2978                        depth_ops: Some(wgpu::Operations {
2979                            load: wgpu::LoadOp::Clear(1.0),
2980                            store: wgpu::StoreOp::Discard,
2981                        }),
2982                        stencil_ops: None,
2983                    }),
2984                    timestamp_writes: None,
2985                    occlusion_query_set: None,
2986                });
2987
2988                pass.set_bind_group(0, camera_bg, &[]);
2989                for outlined in outlines {
2990                    let Some(mesh) = self
2991                        .resources
2992                        .mesh_store
2993                        .get(outlined.mesh_id)
2994                    else {
2995                        continue;
2996                    };
2997                    let pipeline = if outlined.two_sided {
2998                        &self.resources.outline_mask_two_sided_pipeline
2999                    } else {
3000                        &self.resources.outline_mask_pipeline
3001                    };
3002                    pass.set_pipeline(pipeline);
3003                    pass.set_bind_group(1, &outlined.mask_bind_group, &[]);
3004                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
3005                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3006                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
3007                }
3008
3009                // Draw Gaussian splat outline discs.  Each splat position expands to
3010                // a screen-space disc in the vertex shader (6 vertices per instance).
3011                // Depth is tested (splats behind selected meshes are culled) but not
3012                // written, so all visible splats in a cloud contribute to the mask.
3013                pass.set_pipeline(&self.resources.splat_outline_mask_pipeline);
3014                for splat in splat_outlines {
3015                    pass.set_bind_group(1, &splat.bind_group, &[]);
3016                    pass.set_vertex_buffer(0, splat.position_buf.slice(..));
3017                    pass.set_vertex_buffer(1, splat.size_buf.slice(..));
3018                    pass.draw(0..6, 0..splat.instance_count);
3019                }
3020
3021                // Draw glyph instances into the mask using the actual instanced
3022                // mesh geometry so the outline follows arrow/sphere shapes.
3023                if !glyph_outline_indices.is_empty() {
3024                    if let Some(pipeline) = self.resources.glyph_outline_mask_pipeline.as_ref() {
3025                        pass.set_pipeline(pipeline);
3026                        for (idx, instance_filter) in glyph_outline_indices {
3027                            if let Some(glyph) = glyph_gpu_data.get(*idx) {
3028                                pass.set_bind_group(0, camera_bg, &[]);
3029                                pass.set_bind_group(1, &glyph.uniform_bind_group, &[]);
3030                                pass.set_bind_group(2, &glyph.instance_bind_group, &[]);
3031                                pass.set_vertex_buffer(0, glyph.mesh_vertex_buffer.slice(..));
3032                                pass.set_index_buffer(glyph.mesh_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3033                                match instance_filter {
3034                                    None => {
3035                                        pass.draw_indexed(0..glyph.mesh_index_count, 0, 0..glyph.instance_count);
3036                                    }
3037                                    Some(indices) => {
3038                                        for &i in indices {
3039                                            pass.draw_indexed(0..glyph.mesh_index_count, 0, i..i + 1);
3040                                        }
3041                                    }
3042                                }
3043                            }
3044                        }
3045                    }
3046                }
3047
3048                // Draw tensor glyph instances into the mask (instanced ellipsoids).
3049                if !tensor_glyph_outline_indices.is_empty() {
3050                    if let Some(pipeline) = self.resources.tensor_glyph_outline_mask_pipeline.as_ref() {
3051                        pass.set_pipeline(pipeline);
3052                        for (idx, instance_filter) in tensor_glyph_outline_indices {
3053                            if let Some(tg) = tensor_glyph_gpu_data.get(*idx) {
3054                                pass.set_bind_group(0, camera_bg, &[]);
3055                                pass.set_bind_group(1, &tg.uniform_bind_group, &[]);
3056                                pass.set_bind_group(2, &tg.instance_bind_group, &[]);
3057                                pass.set_vertex_buffer(0, tg.mesh_vertex_buffer.slice(..));
3058                                pass.set_index_buffer(tg.mesh_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3059                                match instance_filter {
3060                                    None => {
3061                                        pass.draw_indexed(0..tg.mesh_index_count, 0, 0..tg.instance_count);
3062                                    }
3063                                    Some(indices) => {
3064                                        for &i in indices {
3065                                            pass.draw_indexed(0..tg.mesh_index_count, 0, i..i + 1);
3066                                        }
3067                                    }
3068                                }
3069                            }
3070                        }
3071                    }
3072                }
3073
3074                // Draw sprite billboards into the mask so the outline matches
3075                // each sprite's actual quad shape and per-instance size.
3076                if !sprite_outline_indices.is_empty() {
3077                    if let Some(pipeline) = self.resources.sprite_outline_mask_pipeline.as_ref() {
3078                        pass.set_pipeline(pipeline);
3079                        for (idx, instance_filter) in sprite_outline_indices {
3080                            if let Some(sprite) = sprite_gpu_data.get(*idx) {
3081                                pass.set_bind_group(0, camera_bg, &[]);
3082                                pass.set_bind_group(1, &sprite.bind_group, &[]);
3083                                pass.set_vertex_buffer(0, sprite.vertex_buffer.slice(..));
3084                                match instance_filter {
3085                                    None => {
3086                                        pass.draw(0..6, 0..sprite.sprite_count);
3087                                    }
3088                                    Some(indices) => {
3089                                        for &i in indices {
3090                                            pass.draw(0..6, i..i + 1);
3091                                        }
3092                                    }
3093                                }
3094                            }
3095                        }
3096                    }
3097                }
3098
3099                // Draw volumes into the mask using a simplified ray march so the
3100                // outline hugs the actual volume silhouette, not the AABB.
3101                if !vol_outline_indices.is_empty() {
3102                    if let Some(pipeline) = self.resources.volume_outline_mask_pipeline.as_ref() {
3103                        pass.set_pipeline(pipeline);
3104                        for &idx in vol_outline_indices {
3105                            if let Some(vol) = self.volume_gpu_data.get(idx) {
3106                                pass.set_bind_group(1, &vol.bind_group, &[]);
3107                                pass.set_vertex_buffer(0, vol.vertex_buffer.slice(..));
3108                                pass.set_index_buffer(vol.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3109                                pass.draw_indexed(0..36, 0, 0..1);
3110                            }
3111                        }
3112                    }
3113                }
3114            }
3115
3116            // Pass 2: fullscreen edge detection (reads mask, writes color).
3117            {
3118                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3119                    label: Some("outline_edge_pass"),
3120                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3121                        view: color_view,
3122                        resolve_target: None,
3123                        ops: wgpu::Operations {
3124                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
3125                            store: wgpu::StoreOp::Store,
3126                        },
3127                        depth_slice: None,
3128                    })],
3129                    depth_stencil_attachment: None,
3130                    timestamp_writes: None,
3131                    occlusion_query_set: None,
3132                });
3133                pass.set_pipeline(&self.resources.outline_edge_pipeline);
3134                pass.set_bind_group(0, edge_bg, &[]);
3135                pass.draw(0..3, 0..1);
3136            }
3137
3138            queue.submit(std::iter::once(encoder.finish()));
3139        }
3140
3141        // ------------------------------------------------------------------
3142        // Sub-object highlight prepare: build GPU geometry from sub-selection
3143        // snapshot when the version has changed since the last frame.
3144        // ------------------------------------------------------------------
3145        {
3146            let w = frame.camera.viewport_size[0];
3147            let h = frame.camera.viewport_size[1];
3148
3149            let has_sub_sel = frame.interaction.sub_selection.is_some();
3150
3151            if has_sub_sel {
3152                let needs_rebuild = {
3153                    let slot = &self.viewport_slots[vp_idx];
3154                    let sel_version_changed = frame
3155                        .interaction
3156                        .sub_selection
3157                        .as_ref()
3158                        .map(|s| slot.sub_highlight_generation != s.version)
3159                        .unwrap_or(slot.sub_highlight_generation != u64::MAX);
3160                    sel_version_changed
3161                        || slot.sub_highlight.is_none()
3162                };
3163                if needs_rebuild {
3164                    self.resources.ensure_sub_highlight_pipelines(device);
3165                    let sel_ref = frame.interaction.sub_selection.as_ref();
3166                    let data = self.resources.build_sub_highlight(
3167                        device,
3168                        queue,
3169                        sel_ref,
3170                        &std::collections::HashMap::new(),
3171                        &[],
3172                        frame.interaction.sub_highlight_face_fill_color,
3173                        frame.interaction.sub_highlight_edge_color,
3174                        frame.interaction.sub_highlight_edge_width_px,
3175                        frame.interaction.sub_highlight_vertex_size_px,
3176                        w,
3177                        h,
3178                    );
3179                    let new_gen = frame
3180                        .interaction
3181                        .sub_selection
3182                        .as_ref()
3183                        .map(|s| s.version)
3184                        .unwrap_or(u64::MAX);
3185                    let slot = &mut self.viewport_slots[vp_idx];
3186                    slot.sub_highlight = Some(data);
3187                    slot.sub_highlight_generation = new_gen;
3188                }
3189            } else {
3190                let slot = &mut self.viewport_slots[vp_idx];
3191                slot.sub_highlight = None;
3192                slot.sub_highlight_generation = u64::MAX;
3193            }
3194        }
3195
3196        // ---------------------------------------------------------------
3197        // Overlay labels
3198        // ---------------------------------------------------------------
3199        self.label_gpu_data = None;
3200        if !frame.overlays.labels.is_empty() {
3201            self.resources.ensure_overlay_text_pipeline(device);
3202            let vp_w = frame.camera.viewport_size[0];
3203            let vp_h = frame.camera.viewport_size[1];
3204            if vp_w > 0.0 && vp_h > 0.0 {
3205                let view = &frame.camera.render_camera.view;
3206                let proj = &frame.camera.render_camera.projection;
3207
3208                // Sort by z_order for correct draw ordering.
3209                let mut sorted_labels: Vec<&crate::renderer::types::LabelItem> =
3210                    frame.overlays.labels.iter().collect();
3211                sorted_labels.sort_by_key(|l| l.z_order);
3212
3213                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
3214
3215                for label in &sorted_labels {
3216                    if label.text.is_empty() || label.opacity <= 0.0 {
3217                        continue;
3218                    }
3219
3220                    // Resolve screen position from anchor.
3221                    let screen_pos = if let Some(sa) = label.screen_anchor {
3222                        Some(sa)
3223                    } else if let Some(wa) = label.world_anchor {
3224                        project_to_screen(wa, view, proj, vp_w, vp_h)
3225                    } else {
3226                        continue;
3227                    };
3228                    let Some(anchor_px) = screen_pos else {
3229                        continue;
3230                    };
3231
3232                    let opacity = label.opacity.clamp(0.0, 1.0);
3233
3234                    // Layout text (with optional word wrapping).
3235                    let layout = if let Some(max_w) = label.max_width {
3236                        self.resources.glyph_atlas.layout_text_wrapped(
3237                            &label.text,
3238                            label.font_size,
3239                            label.font,
3240                            max_w,
3241                            device,
3242                        )
3243                    } else {
3244                        self.resources.glyph_atlas.layout_text(
3245                            &label.text,
3246                            label.font_size,
3247                            label.font,
3248                            device,
3249                        )
3250                    };
3251
3252                    // Compute ascent so glyphs are positioned below the anchor.
3253                    let font_index = label.font.map_or(0, |h| h.0);
3254                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, label.font_size);
3255
3256                    // Horizontal alignment.
3257                    let align_offset = match label.anchor_align {
3258                        crate::renderer::types::LabelAnchor::Leading => 6.0,
3259                        crate::renderer::types::LabelAnchor::Center => -layout.total_width * 0.5,
3260                        crate::renderer::types::LabelAnchor::Trailing => -layout.total_width - 6.0,
3261                    };
3262
3263                    // Text origin with alignment + user offset.
3264                    let text_x = anchor_px[0] + align_offset + label.offset[0];
3265                    let text_y = anchor_px[1] - layout.height * 0.5 + label.offset[1];
3266
3267                    // Background box (drawn first, behind text).
3268                    if label.background {
3269                        let pad = label.padding;
3270                        let bx0 = text_x - pad;
3271                        let by0 = text_y - pad;
3272                        let bx1 = text_x + layout.total_width + pad;
3273                        let by1 = text_y + layout.height + pad;
3274                        let bg_color = apply_opacity(label.background_color, opacity);
3275                        if label.border_radius > 0.0 {
3276                            emit_rounded_quad(
3277                                &mut verts,
3278                                bx0, by0, bx1, by1,
3279                                label.border_radius,
3280                                bg_color,
3281                                vp_w, vp_h,
3282                            );
3283                        } else {
3284                            emit_solid_quad(
3285                                &mut verts,
3286                                bx0, by0, bx1, by1,
3287                                bg_color,
3288                                vp_w, vp_h,
3289                            );
3290                        }
3291                    }
3292
3293                    // Leader line.
3294                    if label.leader_line {
3295                        if let Some(wa) = label.world_anchor {
3296                            let world_px = project_to_screen(wa, view, proj, vp_w, vp_h);
3297                            if let Some(wp) = world_px {
3298                                emit_line_quad(
3299                                    &mut verts,
3300                                    wp[0], wp[1],
3301                                    text_x, text_y + layout.height * 0.5,
3302                                    1.5,
3303                                    apply_opacity(label.leader_color, opacity),
3304                                    vp_w, vp_h,
3305                                );
3306                            }
3307                        }
3308                    }
3309
3310                    // Glyph quads.
3311                    let text_color = apply_opacity(label.color, opacity);
3312                    for gq in &layout.quads {
3313                        let gx = text_x + gq.pos[0];
3314                        let gy = text_y + ascent + gq.pos[1];
3315                        emit_textured_quad(
3316                            &mut verts,
3317                            gx, gy,
3318                            gx + gq.size[0], gy + gq.size[1],
3319                            gq.uv_min, gq.uv_max,
3320                            text_color,
3321                            vp_w, vp_h,
3322                        );
3323                    }
3324                }
3325
3326                // Upload atlas if new glyphs were rasterized.
3327                self.resources.glyph_atlas.upload_if_dirty(queue);
3328
3329                if !verts.is_empty() {
3330                    let vertex_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3331                        label: Some("overlay_label_vbuf"),
3332                        contents: bytemuck::cast_slice(&verts),
3333                        usage: wgpu::BufferUsages::VERTEX,
3334                    });
3335                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3336                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3337                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3338                        label: Some("overlay_label_bg"),
3339                        layout: bgl,
3340                        entries: &[
3341                            wgpu::BindGroupEntry {
3342                                binding: 0,
3343                                resource: wgpu::BindingResource::TextureView(
3344                                    &self.resources.glyph_atlas.view,
3345                                ),
3346                            },
3347                            wgpu::BindGroupEntry {
3348                                binding: 1,
3349                                resource: wgpu::BindingResource::Sampler(sampler),
3350                            },
3351                        ],
3352                    });
3353                    self.label_gpu_data = Some(crate::resources::LabelGpuData {
3354                        vertex_buf,
3355                        vertex_count: verts.len() as u32,
3356                        bind_group,
3357                    });
3358                }
3359            }
3360        }
3361
3362        // ---------------------------------------------------------------
3363        // Scalar bars
3364        // ---------------------------------------------------------------
3365        self.scalar_bar_gpu_data = None;
3366        if !frame.overlays.scalar_bars.is_empty() {
3367            self.resources.ensure_overlay_text_pipeline(device);
3368            let vp_w = frame.camera.viewport_size[0];
3369            let vp_h = frame.camera.viewport_size[1];
3370            if vp_w > 0.0 && vp_h > 0.0 {
3371                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
3372
3373                for bar in &frame.overlays.scalar_bars {
3374                    // Clone the LUT immediately so the immutable borrow on self.resources
3375                    // is released before the mutable glyph_atlas borrow below.
3376                    let Some(lut) = self.resources.get_colormap_rgba(bar.colormap_id).map(|l| l.to_vec()) else {
3377                        continue;
3378                    };
3379
3380                    let is_vertical = matches!(
3381                        bar.orientation,
3382                        crate::renderer::types::ScalarBarOrientation::Vertical
3383                    );
3384                    let reversed = bar.ticks_reversed;
3385
3386                    // Effective font sizes.
3387                    let tick_fs = bar.font_size;
3388                    let title_fs = bar.title_font_size.unwrap_or(bar.font_size);
3389                    let font_index = bar.font.map_or(0, |h| h.0);
3390
3391                    // Actual pixel dimensions of the gradient strip.
3392                    let (strip_w, strip_h) = if is_vertical {
3393                        (bar.bar_width_px, bar.bar_length_px)
3394                    } else {
3395                        (bar.bar_length_px, bar.bar_width_px)
3396                    };
3397
3398                    // Pre-compute tick texts and their widths so the background box
3399                    // can be sized to cover the tick labels.
3400                    let tick_count = bar.tick_count.max(2);
3401                    let mut tick_data: Vec<(String, f32, f32)> = Vec::new(); // (text, total_w, height)
3402                    let mut max_tick_w = 0.0f32;
3403                    let mut tick_h = 0.0f32;
3404                    for i in 0..tick_count {
3405                        let t = i as f32 / (tick_count - 1) as f32;
3406                        let value = bar.scalar_min + t * (bar.scalar_max - bar.scalar_min);
3407                        let text = format!("{value:.2}");
3408                        let layout = self.resources.glyph_atlas.layout_text(
3409                            &text, tick_fs, bar.font, device,
3410                        );
3411                        max_tick_w = max_tick_w.max(layout.total_width);
3412                        tick_h = layout.height;
3413                        tick_data.push((text, layout.total_width, layout.height));
3414                    }
3415
3416                    // Vertical space reserved above the gradient strip.
3417                    // In vertical mode the top/bottom tick labels are centred on the strip
3418                    // endpoints, so they each overhang by tick_h/2. title_h must absorb the
3419                    // top overhang AND leave a gap so the title text does not touch the tick.
3420                    let half_tick = tick_h / 2.0;
3421                    let title_h = if bar.title.is_some() {
3422                        // title text height + small gap + top-tick overhang
3423                        title_fs + 4.0 + half_tick
3424                    } else {
3425                        // no title, but still need room for the top-tick overhang
3426                        half_tick
3427                    };
3428
3429                    // Pre-compute title width before bar_x/bar_y so the overhang can
3430                    // be used to push the strip inward and prevent clipping.
3431                    let title_w = if let Some(ref t) = bar.title {
3432                        self.resources.glyph_atlas.layout_text(t, title_fs, bar.font, device).total_width
3433                    } else {
3434                        0.0
3435                    };
3436
3437                    // How far title / tick labels spill beyond the strip on each side.
3438                    // Vertical: title centred on the narrow strip, ticks to the right.
3439                    //   left side: title overhang only.
3440                    //   right side: ticks dominate (strip_w + 4 + max_tick_w).
3441                    // Horizontal: both title and tick labels can overhang left/right equally.
3442                    let bg_pad = 4.0;
3443                    let (inset_left, inset_right) = if is_vertical {
3444                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
3445                        let right_extent = 4.0 + max_tick_w + bg_pad; // relative to strip right edge
3446                        (title_oh + bg_pad, right_extent)
3447                    } else {
3448                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
3449                        let tick_oh  = max_tick_w / 2.0;
3450                        let side = title_oh.max(tick_oh) + bg_pad;
3451                        (side, side)
3452                    };
3453
3454                    // How far content hangs below the strip bottom (used to keep the
3455                    // background box flush with margin_px on the bottom-anchored side).
3456                    // Vertical: bottom tick label is centred on the strip endpoint -> half_tick.
3457                    // Horizontal: tick labels sit fully below the strip -> 3 + tick_h.
3458                    let bottom_overhang = if is_vertical { half_tick } else { 3.0 + tick_h };
3459
3460                    // Top-left of the gradient strip.
3461                    // bg_pad is added/subtracted here so that the background box edge lands
3462                    // exactly at margin_px from the viewport edge on the anchored side.
3463                    //   Top anchor:    bg_y0 = bar_y - title_h - bg_pad  =>  set bar_y = margin_px + title_h + bg_pad
3464                    //   Bottom anchor: bg_y1 = bar_y + strip_h + bottom_overhang + bg_pad  =>  bar_y = vp_h - margin_px - strip_h - bottom_overhang - bg_pad
3465                    let (bar_x, bar_y) = match bar.anchor {
3466                        crate::renderer::types::ScalarBarAnchor::TopLeft => (
3467                            bar.margin_px + inset_left,
3468                            bar.margin_px + title_h + bg_pad,
3469                        ),
3470                        crate::renderer::types::ScalarBarAnchor::TopRight => (
3471                            vp_w - bar.margin_px - strip_w - inset_right,
3472                            bar.margin_px + title_h + bg_pad,
3473                        ),
3474                        crate::renderer::types::ScalarBarAnchor::BottomLeft => (
3475                            bar.margin_px + inset_left,
3476                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
3477                        ),
3478                        crate::renderer::types::ScalarBarAnchor::BottomRight => (
3479                            vp_w - bar.margin_px - strip_w - inset_right,
3480                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
3481                        ),
3482                    };
3483
3484                    // Background box: now that bar_x/bar_y are inset, the box stays on screen.
3485                    let (bg_x0, bg_y0, bg_x1, bg_y1) = if is_vertical {
3486                        let title_right = bar_x + (strip_w + title_w) / 2.0;
3487                        let ticks_right = bar_x + strip_w + 4.0 + max_tick_w;
3488                        (
3489                            bar_x - bg_pad - ((title_w - strip_w) / 2.0).max(0.0),
3490                            bar_y - title_h - bg_pad,
3491                            ticks_right.max(title_right) + bg_pad,
3492                            bar_y + strip_h + half_tick + bg_pad,
3493                        )
3494                    } else {
3495                        let title_overhang = ((title_w - strip_w) / 2.0).max(0.0);
3496                        let tick_overhang  = max_tick_w / 2.0;
3497                        let side_pad = title_overhang.max(tick_overhang);
3498                        let bottom = bar_y + strip_h + 3.0 + tick_h + bg_pad;
3499                        (
3500                            bar_x - bg_pad - side_pad,
3501                            bar_y - title_h - bg_pad,
3502                            bar_x + strip_w + bg_pad + side_pad,
3503                            bottom,
3504                        )
3505                    };
3506                    emit_rounded_quad(
3507                        &mut verts,
3508                        bg_x0, bg_y0, bg_x1, bg_y1,
3509                        3.0,
3510                        bar.background_color,
3511                        vp_w, vp_h,
3512                    );
3513
3514                    // Gradient strip: 64 solid quads sampled from the colormap LUT.
3515                    let steps: usize = 64;
3516                    for s in 0..steps {
3517                        let (qx0, qy0, qx1, qy1, t) = if is_vertical {
3518                            // Default: top = max (t=1). Reversed: top = min (t=0).
3519                            let t = if reversed {
3520                                s as f32 / (steps - 1) as f32
3521                            } else {
3522                                1.0 - s as f32 / (steps - 1) as f32
3523                            };
3524                            let step_h = strip_h / steps as f32;
3525                            let sy = bar_y + s as f32 * step_h;
3526                            (bar_x, sy, bar_x + strip_w, sy + step_h + 0.5, t)
3527                        } else {
3528                            // Default: left = min (t=0). Reversed: left = max (t=1).
3529                            let t = if reversed {
3530                                1.0 - s as f32 / (steps - 1) as f32
3531                            } else {
3532                                s as f32 / (steps - 1) as f32
3533                            };
3534                            let step_w = strip_w / steps as f32;
3535                            let sx = bar_x + s as f32 * step_w;
3536                            (sx, bar_y, sx + step_w + 0.5, bar_y + strip_h, t)
3537                        };
3538                        let lut_idx = (t * 255.0).clamp(0.0, 255.0) as usize;
3539                        let [r, g, b, a] = lut[lut_idx];
3540                        let color = [
3541                            r as f32 / 255.0,
3542                            g as f32 / 255.0,
3543                            b as f32 / 255.0,
3544                            a as f32 / 255.0,
3545                        ];
3546                        emit_solid_quad(&mut verts, qx0, qy0, qx1, qy1, color, vp_w, vp_h);
3547                    }
3548
3549                    // Tick labels.
3550                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, tick_fs);
3551                    for (i, (text, tw, th)) in tick_data.iter().enumerate() {
3552                        let t = i as f32 / (tick_count - 1) as f32;
3553                        let layout = self.resources.glyph_atlas.layout_text(
3554                            text, tick_fs, bar.font, device,
3555                        );
3556
3557                        let (lx, ly) = if is_vertical {
3558                            // Place text to the right of the strip, vertically centered
3559                            // on its tick position.
3560                            // Default: top=max -> progress = 1.0-t puts max at top.
3561                            // Reversed: top=min -> progress = t puts min at top.
3562                            let progress = if reversed { t } else { 1.0 - t };
3563                            let tick_y = bar_y + progress * strip_h;
3564                            (bar_x + strip_w + 4.0, tick_y - th * 0.5)
3565                        } else {
3566                            // Place text below the strip, horizontally centered on its tick.
3567                            // Default: left=min -> tick at t*strip_w.
3568                            // Reversed: left=max -> tick at (1-t)*strip_w.
3569                            let frac = if reversed { 1.0 - t } else { t };
3570                            let tick_x = bar_x + frac * strip_w;
3571                            (tick_x - tw * 0.5, bar_y + strip_h + 3.0)
3572                        };
3573                        let _ = (tw, th); // used above
3574
3575                        for gq in &layout.quads {
3576                            let gx = lx + gq.pos[0];
3577                            let gy = ly + ascent + gq.pos[1];
3578                            emit_textured_quad(
3579                                &mut verts,
3580                                gx, gy,
3581                                gx + gq.size[0], gy + gq.size[1],
3582                                gq.uv_min, gq.uv_max,
3583                                bar.label_color,
3584                                vp_w, vp_h,
3585                            );
3586                        }
3587                    }
3588
3589                    // Optional title above the gradient strip.
3590                    if let Some(ref title_text) = bar.title {
3591                        let layout = self.resources.glyph_atlas.layout_text(
3592                            title_text, title_fs, bar.font, device,
3593                        );
3594                        let title_ascent = self.resources.glyph_atlas.font_ascent(font_index, title_fs);
3595                        // Centre the title over the gradient strip.
3596                        let tx = bar_x + (strip_w - layout.total_width) * 0.5;
3597                        let ty = bar_y - title_h;
3598                        for gq in &layout.quads {
3599                            let gx = tx + gq.pos[0];
3600                            let gy = ty + title_ascent + gq.pos[1];
3601                            emit_textured_quad(
3602                                &mut verts,
3603                                gx, gy,
3604                                gx + gq.size[0], gy + gq.size[1],
3605                                gq.uv_min, gq.uv_max,
3606                                bar.label_color,
3607                                vp_w, vp_h,
3608                            );
3609                        }
3610                    }
3611                }
3612
3613                // Upload any newly rasterized glyphs (may overlap with label upload above).
3614                self.resources.glyph_atlas.upload_if_dirty(queue);
3615
3616                if !verts.is_empty() {
3617                    let vertex_buf =
3618                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3619                            label: Some("overlay_scalar_bar_vbuf"),
3620                            contents: bytemuck::cast_slice(&verts),
3621                            usage: wgpu::BufferUsages::VERTEX,
3622                        });
3623                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3624                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3625                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3626                        label: Some("overlay_scalar_bar_bg"),
3627                        layout: bgl,
3628                        entries: &[
3629                            wgpu::BindGroupEntry {
3630                                binding: 0,
3631                                resource: wgpu::BindingResource::TextureView(
3632                                    &self.resources.glyph_atlas.view,
3633                                ),
3634                            },
3635                            wgpu::BindGroupEntry {
3636                                binding: 1,
3637                                resource: wgpu::BindingResource::Sampler(sampler),
3638                            },
3639                        ],
3640                    });
3641                    self.scalar_bar_gpu_data = Some(crate::resources::LabelGpuData {
3642                        vertex_buf,
3643                        vertex_count: verts.len() as u32,
3644                        bind_group,
3645                    });
3646                }
3647            }
3648        }
3649
3650        // ---------------------------------------------------------------
3651        // Rulers
3652        // ---------------------------------------------------------------
3653        self.ruler_gpu_data = None;
3654        if !frame.overlays.rulers.is_empty() {
3655            self.resources.ensure_overlay_text_pipeline(device);
3656            let vp_w = frame.camera.viewport_size[0];
3657            let vp_h = frame.camera.viewport_size[1];
3658            if vp_w > 0.0 && vp_h > 0.0 {
3659                let view = &frame.camera.render_camera.view;
3660                let proj = &frame.camera.render_camera.projection;
3661
3662                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
3663
3664                for ruler in &frame.overlays.rulers {
3665                    // Project both endpoints to NDC (returns None only if behind camera).
3666                    let start_ndc = project_to_ndc(ruler.start, view, proj);
3667                    let end_ndc   = project_to_ndc(ruler.end,   view, proj);
3668
3669                    // Cull entirely when either endpoint is behind the camera.
3670                    let (Some(sndc), Some(endc)) = (start_ndc, end_ndc) else { continue };
3671
3672                    // Clip the segment to the viewport NDC box [-1,1]^2.
3673                    // This keeps the line visible when only one end is off-screen sideways.
3674                    let Some((csndc, cendc)) = clip_line_ndc(sndc, endc) else { continue };
3675
3676                    let [sx, sy] = ndc_to_screen_px(csndc, vp_w, vp_h);
3677                    let [ex, ey] = ndc_to_screen_px(cendc, vp_w, vp_h);
3678
3679                    // Track which original endpoints are within the viewport (for end caps).
3680                    let start_on_screen = ndc_in_viewport(sndc);
3681                    let end_on_screen   = ndc_in_viewport(endc);
3682
3683                    // Main ruler line.
3684                    emit_line_quad(
3685                        &mut verts,
3686                        sx, sy, ex, ey,
3687                        ruler.line_width_px,
3688                        ruler.color,
3689                        vp_w, vp_h,
3690                    );
3691
3692                    // End caps only at endpoints that are actually on screen.
3693                    if ruler.end_caps {
3694                        let dx = ex - sx;
3695                        let dy = ey - sy;
3696                        let len = (dx * dx + dy * dy).sqrt().max(0.001);
3697                        let cap_half = 5.0;
3698                        let px = -dy / len * cap_half;
3699                        let py =  dx / len * cap_half;
3700
3701                        if start_on_screen {
3702                            emit_line_quad(
3703                                &mut verts,
3704                                sx - px, sy - py,
3705                                sx + px, sy + py,
3706                                ruler.line_width_px,
3707                                ruler.color,
3708                                vp_w, vp_h,
3709                            );
3710                        }
3711                        if end_on_screen {
3712                            emit_line_quad(
3713                                &mut verts,
3714                                ex - px, ey - py,
3715                                ex + px, ey + py,
3716                                ruler.line_width_px,
3717                                ruler.color,
3718                                vp_w, vp_h,
3719                            );
3720                        }
3721                    }
3722
3723                    // Distance label: always shows true 3D distance.
3724                    // Place it at the midpoint of the visible (clipped) segment.
3725                    let start_world = glam::Vec3::from(ruler.start);
3726                    let end_world = glam::Vec3::from(ruler.end);
3727                    let distance = (end_world - start_world).length();
3728                    let text = format_ruler_distance(distance, ruler.label_format.as_deref());
3729
3730                    let mid_x = (sx + ex) * 0.5;
3731                    let mid_y = (sy + ey) * 0.5;
3732
3733                    let layout = self.resources.glyph_atlas.layout_text(
3734                        &text,
3735                        ruler.font_size,
3736                        ruler.font,
3737                        device,
3738                    );
3739                    let font_index = ruler.font.map_or(0, |h| h.0);
3740                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, ruler.font_size);
3741
3742                    // Center the label above the midpoint with a small gap.
3743                    let lx = mid_x - layout.total_width * 0.5;
3744                    let ly = mid_y - layout.height - 6.0;
3745
3746                    // Semi-transparent background box.
3747                    let pad = 3.0;
3748                    emit_solid_quad(
3749                        &mut verts,
3750                        lx - pad, ly - pad,
3751                        lx + layout.total_width + pad, ly + layout.height + pad,
3752                        [0.0, 0.0, 0.0, 0.55],
3753                        vp_w, vp_h,
3754                    );
3755
3756                    // Glyph quads.
3757                    for gq in &layout.quads {
3758                        let gx = lx + gq.pos[0];
3759                        let gy = ly + ascent + gq.pos[1];
3760                        emit_textured_quad(
3761                            &mut verts,
3762                            gx, gy,
3763                            gx + gq.size[0], gy + gq.size[1],
3764                            gq.uv_min, gq.uv_max,
3765                            ruler.label_color,
3766                            vp_w, vp_h,
3767                        );
3768                    }
3769                }
3770
3771                // Upload any newly rasterized glyphs.
3772                self.resources.glyph_atlas.upload_if_dirty(queue);
3773
3774                if !verts.is_empty() {
3775                    let vertex_buf =
3776                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3777                            label: Some("overlay_ruler_vbuf"),
3778                            contents: bytemuck::cast_slice(&verts),
3779                            usage: wgpu::BufferUsages::VERTEX,
3780                        });
3781                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3782                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3783                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3784                        label: Some("overlay_ruler_bg"),
3785                        layout: bgl,
3786                        entries: &[
3787                            wgpu::BindGroupEntry {
3788                                binding: 0,
3789                                resource: wgpu::BindingResource::TextureView(
3790                                    &self.resources.glyph_atlas.view,
3791                                ),
3792                            },
3793                            wgpu::BindGroupEntry {
3794                                binding: 1,
3795                                resource: wgpu::BindingResource::Sampler(sampler),
3796                            },
3797                        ],
3798                    });
3799                    self.ruler_gpu_data = Some(crate::resources::LabelGpuData {
3800                        vertex_buf,
3801                        vertex_count: verts.len() as u32,
3802                        bind_group,
3803                    });
3804                }
3805            }
3806        }
3807
3808        // ---------------------------------------------------------------
3809        // Loading bars
3810        // ---------------------------------------------------------------
3811        self.loading_bar_gpu_data = None;
3812        if !frame.overlays.loading_bars.is_empty() {
3813            self.resources.ensure_overlay_text_pipeline(device);
3814            let vp_w = frame.camera.viewport_size[0];
3815            let vp_h = frame.camera.viewport_size[1];
3816            if vp_w > 0.0 && vp_h > 0.0 {
3817                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
3818
3819                for bar in &frame.overlays.loading_bars {
3820                    // Bar top-left corner based on anchor.
3821                    let bar_x = vp_w * 0.5 - bar.width_px * 0.5;
3822                    let bar_y = match bar.anchor {
3823                        crate::renderer::types::LoadingBarAnchor::TopCenter => bar.margin_px,
3824                        crate::renderer::types::LoadingBarAnchor::Center => {
3825                            vp_h * 0.5 - bar.height_px * 0.5
3826                        }
3827                        crate::renderer::types::LoadingBarAnchor::BottomCenter => {
3828                            vp_h - bar.margin_px - bar.height_px
3829                        }
3830                    };
3831
3832                    // Label above (TopCenter: below) the bar.
3833                    if let Some(ref text) = bar.label {
3834                        let layout = self.resources.glyph_atlas.layout_text(
3835                            text,
3836                            bar.font_size,
3837                            None,
3838                            device,
3839                        );
3840                        let ascent =
3841                            self.resources.glyph_atlas.font_ascent(0, bar.font_size);
3842                        let label_gap = 5.0;
3843                        let lx = bar_x + bar.width_px * 0.5 - layout.total_width * 0.5;
3844                        let ly = match bar.anchor {
3845                            crate::renderer::types::LoadingBarAnchor::TopCenter => {
3846                                bar_y + bar.height_px + label_gap
3847                            }
3848                            _ => bar_y - layout.height - label_gap,
3849                        };
3850                        for gq in &layout.quads {
3851                            let gx = lx + gq.pos[0];
3852                            let gy = ly + ascent + gq.pos[1];
3853                            emit_textured_quad(
3854                                &mut verts,
3855                                gx, gy,
3856                                gx + gq.size[0], gy + gq.size[1],
3857                                gq.uv_min, gq.uv_max,
3858                                bar.label_color,
3859                                vp_w, vp_h,
3860                            );
3861                        }
3862                    }
3863
3864                    // Background rectangle.
3865                    emit_rounded_quad(
3866                        &mut verts,
3867                        bar_x, bar_y,
3868                        bar_x + bar.width_px, bar_y + bar.height_px,
3869                        bar.corner_radius,
3870                        bar.background_color,
3871                        vp_w, vp_h,
3872                    );
3873
3874                    // Fill rectangle clipped to progress fraction.
3875                    let fill_w = bar.width_px * bar.progress.clamp(0.0, 1.0);
3876                    if fill_w > 0.5 {
3877                        emit_rounded_quad(
3878                            &mut verts,
3879                            bar_x, bar_y,
3880                            bar_x + fill_w, bar_y + bar.height_px,
3881                            bar.corner_radius,
3882                            bar.fill_color,
3883                            vp_w, vp_h,
3884                        );
3885                    }
3886                }
3887
3888                self.resources.glyph_atlas.upload_if_dirty(queue);
3889
3890                if !verts.is_empty() {
3891                    let vertex_buf =
3892                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3893                            label: Some("loading_bar_vbuf"),
3894                            contents: bytemuck::cast_slice(&verts),
3895                            usage: wgpu::BufferUsages::VERTEX,
3896                        });
3897                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3898                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3899                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3900                        label: Some("loading_bar_bg"),
3901                        layout: bgl,
3902                        entries: &[
3903                            wgpu::BindGroupEntry {
3904                                binding: 0,
3905                                resource: wgpu::BindingResource::TextureView(
3906                                    &self.resources.glyph_atlas.view,
3907                                ),
3908                            },
3909                            wgpu::BindGroupEntry {
3910                                binding: 1,
3911                                resource: wgpu::BindingResource::Sampler(sampler),
3912                            },
3913                        ],
3914                    });
3915                    self.loading_bar_gpu_data = Some(crate::resources::LabelGpuData {
3916                        vertex_buf,
3917                        vertex_count: verts.len() as u32,
3918                        bind_group,
3919                    });
3920                }
3921            }
3922        }
3923
3924        // ------------------------------------------------------------------
3925        // Gaussian splat: per-viewport GPU sort.
3926        // ------------------------------------------------------------------
3927        self.gaussian_splat_draw_data.clear();
3928        if !frame.scene.gaussian_splats.is_empty() {
3929            self.resources.ensure_gaussian_splat_pipelines(device);
3930            let vp_idx = frame.camera.viewport_index;
3931            let eye = frame.camera.render_camera.eye_position;
3932            let vp_w = frame.camera.viewport_size[0].max(1.0);
3933            let vp_h = frame.camera.viewport_size[1].max(1.0);
3934            for item in &frame.scene.gaussian_splats {
3935                let store_index = item.id.0;
3936                if self.resources.gaussian_splat_store.get(store_index).is_none() {
3937                    continue;
3938                }
3939                let sh_degree = self
3940                    .resources
3941                    .gaussian_splat_store
3942                    .get(store_index)
3943                    .unwrap()
3944                    .sh_degree;
3945                let count = self
3946                    .resources
3947                    .gaussian_splat_store
3948                    .get(store_index)
3949                    .unwrap()
3950                    .count;
3951                self.resources.run_gaussian_splat_sort(
3952                    device,
3953                    queue,
3954                    store_index,
3955                    vp_idx,
3956                    eye,
3957                    item.model,
3958                    vp_w,
3959                    vp_h,
3960                    sh_degree,
3961                );
3962                self.gaussian_splat_draw_data.push(
3963                    crate::resources::GaussianSplatDrawData {
3964                        store_index,
3965                        viewport_index: vp_idx,
3966                        model: item.model,
3967                        count,
3968                    },
3969                );
3970            }
3971        }
3972    }
3973
3974    /// Upload per-frame data to GPU buffers and render the shadow pass.
3975    /// Call before `paint()`.
3976    ///
3977    /// Returns [`crate::FrameStats`] with per-frame timing and upload metrics.
3978    pub fn prepare(
3979        &mut self,
3980        device: &wgpu::Device,
3981        queue: &wgpu::Queue,
3982        frame: &FrameData,
3983    ) -> crate::renderer::stats::FrameStats {
3984        let prepare_start = std::time::Instant::now();
3985
3986        // Phase 4 : read back GPU timestamps from the previous frame, if available.
3987        // By the time prepare() is called, the previous frame's queue.submit() has
3988        // already happened, so it is safe to initiate the map here.
3989        if self.ts_needs_readback {
3990            if let Some(ref stg_buf) = self.ts_staging_buf {
3991                let (tx, rx) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3992                stg_buf.slice(..).map_async(wgpu::MapMode::Read, move |r| {
3993                    let _ = tx.send(r);
3994                });
3995                // Non-blocking poll: flush any completed callbacks. GPU work from the
3996                // previous frame is almost certainly done by the time CPU reaches here.
3997                device
3998                    .poll(wgpu::PollType::Wait {
3999                        submission_index: None,
4000                        timeout: Some(std::time::Duration::from_millis(100)),
4001                    })
4002                    .ok();
4003                if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
4004                    let data = stg_buf.slice(..).get_mapped_range();
4005                    let t0 = u64::from_le_bytes(data[0..8].try_into().unwrap());
4006                    let t1 = u64::from_le_bytes(data[8..16].try_into().unwrap());
4007                    drop(data);
4008                    // ts_period is nanoseconds/tick; convert delta to milliseconds.
4009                    let gpu_ms = t1.saturating_sub(t0) as f32 * self.ts_period / 1_000_000.0;
4010                    self.last_stats.gpu_frame_ms = Some(gpu_ms);
4011                }
4012                stg_buf.unmap();
4013            }
4014            self.ts_needs_readback = false;
4015        }
4016
4017        // Read back GPU-visible instance count from the previous frame's indirect args copy.
4018        // The cull pass from the previous frame has already been submitted and is almost
4019        // certainly done by the time prepare() is called; a short poll is enough.
4020        if self.indirect_readback_pending {
4021            if let Some(ref stg_buf) = self.indirect_readback_buf {
4022                let bytes = self.indirect_readback_batch_count as u64 * 20;
4023                if bytes > 0 {
4024                    let (tx, rx) =
4025                        std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
4026                    stg_buf.slice(..bytes).map_async(wgpu::MapMode::Read, move |r| {
4027                        let _ = tx.send(r);
4028                    });
4029                    device
4030                        .poll(wgpu::PollType::Wait {
4031                            submission_index: None,
4032                            timeout: Some(std::time::Duration::from_millis(100)),
4033                        })
4034                        .ok();
4035                    if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
4036                        let data = stg_buf.slice(..bytes).get_mapped_range();
4037                        let mut visible: u32 = 0;
4038                        for i in 0..self.indirect_readback_batch_count as usize {
4039                            // DrawIndexedIndirect layout: [index_count, instance_count, first_index, base_vertex, first_instance]
4040                            // instance_count is at byte offset 4 within each 20-byte entry.
4041                            let off = i * 20 + 4;
4042                            let n = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
4043                            visible = visible.saturating_add(n);
4044                        }
4045                        drop(data);
4046                        self.last_stats.gpu_visible_instances = Some(visible);
4047                    }
4048                    stg_buf.unmap();
4049                }
4050            }
4051            self.indirect_readback_pending = false;
4052        }
4053
4054        // Wall-clock duration since the previous prepare() call approximates the frame interval.
4055        let total_frame_ms = self
4056            .last_prepare_instant
4057            .map(|t| t.elapsed().as_secs_f32() * 1000.0)
4058            .unwrap_or(0.0);
4059
4060        // Snapshot geometry upload bytes accumulated since the last frame, then reset.
4061        let upload_bytes = self.resources.frame_upload_bytes;
4062        self.resources.frame_upload_bytes = 0;
4063
4064        // Resolve effective scale bounds and degradation flags.
4065        // When a preset is set it overrides the individual fields; the individual
4066        // fields are preserved so they restore when switching back to None.
4067        let policy = self.performance_policy;
4068        let (eff_min_scale, eff_max_scale, eff_allow_shadows, eff_allow_volumes, eff_allow_effects) =
4069            match policy.preset {
4070                Some(crate::renderer::stats::QualityPreset::High) => {
4071                    (1.0_f32, 1.0_f32, false, false, false)
4072                }
4073                Some(crate::renderer::stats::QualityPreset::Medium) => {
4074                    (0.75_f32, 1.0_f32, true, false, true)
4075                }
4076                Some(crate::renderer::stats::QualityPreset::Low) => {
4077                    (0.5_f32, 0.75_f32, true, true, true)
4078                }
4079                None => (
4080                    policy.min_render_scale,
4081                    policy.max_render_scale,
4082                    policy.allow_shadow_reduction,
4083                    policy.allow_volume_quality_reduction,
4084                    policy.allow_effect_throttling,
4085                ),
4086            };
4087
4088        // Capture mode: force max render scale and suppress all degradation.
4089        // The adaptation controller is paused for the duration of the frame.
4090        let in_capture = self.runtime_mode == crate::renderer::stats::RuntimeMode::Capture;
4091        if in_capture {
4092            self.current_render_scale = eff_max_scale;
4093        }
4094
4095        // HDR path detection: post_process.enabled means render()/render_viewport()
4096        // will be called. Dynamic resolution is not implemented for the HDR path
4097        // (post-tonemap passes pair output_view with hdr_depth_view and require
4098        // matching dimensions). Suppress the controller and pin render_scale to 1.0
4099        // so FrameStats does not report a misleading value.
4100        let hdr_active = frame.effects.post_process.enabled;
4101
4102        // When a preset is active, clamp current_render_scale to the preset's bounds
4103        // immediately, without requiring allow_dynamic_resolution. This ensures the
4104        // preset has a visible effect even when the adaptation controller is off.
4105        // The controller can still adjust within these bounds when enabled.
4106        if !in_capture && !hdr_active && policy.preset.is_some() {
4107            self.current_render_scale =
4108                self.current_render_scale.clamp(eff_min_scale, eff_max_scale);
4109        }
4110
4111        // Tiered degradation ladder.
4112        // Order: render scale -> shadows -> volumes -> effects.
4113        // The tier advances one step per over-budget frame once render scale has
4114        // reached its minimum (nothing more the controller can reduce).
4115        // The tier retreats one step per frame that is comfortably under budget,
4116        // reversing the ladder in the same order (effects first).
4117        // Capture mode resets the tier; HDR path leaves it unchanged but flags
4118        // are suppressed below regardless.
4119        let missed_prev = self.last_stats.missed_budget;
4120        let under_prev = !self.last_stats.missed_budget
4121            && policy
4122                .target_fps
4123                .map(|fps| {
4124                    let budget = 1000.0 / fps;
4125                    let sig = self
4126                        .last_stats
4127                        .gpu_frame_ms
4128                        .unwrap_or(self.last_stats.total_frame_ms);
4129                    sig < budget * 0.8
4130                })
4131                .unwrap_or(true);
4132        if in_capture {
4133            self.degradation_tier = 0;
4134        } else if !hdr_active {
4135            let at_min = !policy.allow_dynamic_resolution
4136                || self.current_render_scale <= eff_min_scale + 0.001;
4137            if missed_prev && at_min {
4138                self.degradation_tier = (self.degradation_tier + 1).min(3);
4139            } else if under_prev {
4140                self.degradation_tier = self.degradation_tier.saturating_sub(1);
4141            }
4142        }
4143
4144        // Derive per-pass flags from the current tier and effective policy.
4145        // All flags are suppressed in Capture mode regardless of tier.
4146        self.degradation_shadows_skipped =
4147            !in_capture && self.degradation_tier >= 1 && eff_allow_shadows;
4148        self.degradation_volume_quality_reduced =
4149            !in_capture && self.degradation_tier >= 2 && eff_allow_volumes;
4150        self.degradation_effects_throttled =
4151            !in_capture && self.degradation_tier >= 3 && eff_allow_effects;
4152
4153        // Cache scene items for renderer.pick() dispatch.
4154        {
4155            let surfaces = match &frame.scene.surfaces {
4156                SurfaceSubmission::Flat(items) => items.as_ref(),
4157            };
4158            self.pick_scene_items = surfaces.to_vec();
4159            self.pick_point_cloud_items = frame.scene.point_clouds.clone();
4160            self.pick_splat_items = frame.scene.gaussian_splats.clone();
4161            self.pick_volume_items = frame.scene.volumes.clone();
4162            self.pick_tvm_items = frame.scene.transparent_volume_meshes.clone();
4163            self.pick_volume_mesh_items = frame.scene.volume_mesh_items.clone();
4164            self.pick_polyline_items = frame.scene.polylines.clone();
4165            self.pick_glyph_items = frame.scene.glyphs.clone();
4166            self.pick_tensor_glyph_items = frame.scene.tensor_glyphs.clone();
4167            self.pick_sprite_items = frame.scene.sprite_items.clone();
4168            self.pick_streamtube_items = frame.scene.streamtube_items.clone();
4169            self.pick_tube_items = frame.scene.tube_items.clone();
4170            self.pick_ribbon_items = frame.scene.ribbon_items.clone();
4171        }
4172
4173        let (scene_fx, viewport_fx) = frame.effects.split();
4174        self.prepare_scene_internal(device, queue, frame, &scene_fx);
4175        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
4176
4177        let cpu_prepare_ms = prepare_start.elapsed().as_secs_f32() * 1000.0;
4178
4179        let budget_ms = policy.target_fps.map(|fps| 1000.0 / fps);
4180
4181        // Controller signal: prefer gpu_frame_ms (excludes vsync wait, one-frame lag is
4182        // acceptable). Fall back to total_frame_ms when GPU timestamps are unavailable:
4183        // it reflects wall-clock frame duration and correctly fires over-budget at low
4184        // frame rates. cpu_prepare_ms is not used as a fallback because it only measures
4185        // CPU-side work and is low even when the GPU or driver is the bottleneck.
4186        let controller_ms = self
4187            .last_stats
4188            .gpu_frame_ms
4189            .unwrap_or(total_frame_ms);
4190
4191        // Capture mode always reports missed_budget = false; degradation is suppressed.
4192        let missed_budget = !in_capture
4193            && budget_ms.map(|b| controller_ms > b).unwrap_or(false);
4194
4195        // Adaptation controller: adjust render scale within effective bounds when enabled.
4196        // Uses controller_ms from the previous frame (gpu_frame_ms when available,
4197        // otherwise total_frame_ms). Paused in Capture mode and when the HDR path is
4198        // active (see hdr_active above).
4199        if policy.allow_dynamic_resolution && !in_capture && !hdr_active {
4200            if let Some(budget) = budget_ms {
4201                if controller_ms > budget {
4202                    // Over budget: step down quickly.
4203                    self.current_render_scale =
4204                        (self.current_render_scale - 0.1).max(eff_min_scale);
4205                } else if controller_ms < budget * 0.8 {
4206                    // Comfortably under budget: recover slowly to avoid oscillation.
4207                    self.current_render_scale =
4208                        (self.current_render_scale + 0.05).min(eff_max_scale);
4209                }
4210            }
4211        }
4212
4213        self.last_prepare_instant = Some(prepare_start);
4214        self.frame_counter = self.frame_counter.wrapping_add(1);
4215
4216        // On the HDR path the render_scale has no effect on output; report 1.0
4217        // so consumers are not misled by a value that is changing but doing nothing.
4218        let reported_render_scale = if hdr_active { 1.0 } else { self.current_render_scale };
4219
4220        let stats = crate::renderer::stats::FrameStats {
4221            cpu_prepare_ms,
4222            // gpu_frame_ms is updated by the timestamp readback above when available;
4223            // propagate the most recent value from last_stats.
4224            gpu_frame_ms: self.last_stats.gpu_frame_ms,
4225            total_frame_ms,
4226            render_scale: reported_render_scale,
4227            missed_budget,
4228            upload_bytes,
4229            shadows_skipped: self.degradation_shadows_skipped,
4230            volume_quality_reduced: self.degradation_volume_quality_reduced,
4231            // effects_throttled is set by the render path; carry forward here so
4232            // prepare()-only callers still see the previous frame's value until
4233            // paint_to()/render() updates it.
4234            effects_throttled: self.degradation_effects_throttled,
4235            ..self.last_stats
4236        };
4237        self.last_stats = stats;
4238        stats
4239    }
4240}
4241
4242// ---------------------------------------------------------------------------
4243// Clip boundary wireframe helpers (used by prepare_viewport_internal)
4244// ---------------------------------------------------------------------------
4245
4246/// Wireframe outline for a clip box (12 edges as 2-point polyline strips).
4247fn clip_box_outline(
4248    center: [f32; 3],
4249    half: [f32; 3],
4250    orientation: [[f32; 3]; 3],
4251    color: [f32; 4],
4252) -> PolylineItem {
4253    let ax = glam::Vec3::from(orientation[0]) * half[0];
4254    let ay = glam::Vec3::from(orientation[1]) * half[1];
4255    let az = glam::Vec3::from(orientation[2]) * half[2];
4256    let c = glam::Vec3::from(center);
4257
4258    let corners = [
4259        c - ax - ay - az,
4260        c + ax - ay - az,
4261        c + ax + ay - az,
4262        c - ax + ay - az,
4263        c - ax - ay + az,
4264        c + ax - ay + az,
4265        c + ax + ay + az,
4266        c - ax + ay + az,
4267    ];
4268    let edges: [(usize, usize); 12] = [
4269        (0, 1),
4270        (1, 2),
4271        (2, 3),
4272        (3, 0), // bottom face
4273        (4, 5),
4274        (5, 6),
4275        (6, 7),
4276        (7, 4), // top face
4277        (0, 4),
4278        (1, 5),
4279        (2, 6),
4280        (3, 7), // verticals
4281    ];
4282
4283    let mut positions = Vec::with_capacity(24);
4284    let mut strip_lengths = Vec::with_capacity(12);
4285    for (a, b) in edges {
4286        positions.push(corners[a].to_array());
4287        positions.push(corners[b].to_array());
4288        strip_lengths.push(2u32);
4289    }
4290
4291    let mut item = PolylineItem::default();
4292    item.positions = positions;
4293    item.strip_lengths = strip_lengths;
4294    item.default_color = color;
4295    item.line_width = 2.0;
4296    item
4297}
4298
4299/// Wireframe outline for a clip sphere (three great circles).
4300fn clip_sphere_outline(center: [f32; 3], radius: f32, color: [f32; 4]) -> PolylineItem {
4301    let c = glam::Vec3::from(center);
4302    let segs = 64usize;
4303    let mut positions = Vec::with_capacity((segs + 1) * 3);
4304    let mut strip_lengths = Vec::with_capacity(3);
4305
4306    for axis in 0..3usize {
4307        let start = positions.len();
4308        for i in 0..=segs {
4309            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
4310            let (s, cs) = t.sin_cos();
4311            let p = c + match axis {
4312                0 => glam::Vec3::new(cs * radius, s * radius, 0.0),
4313                1 => glam::Vec3::new(cs * radius, 0.0, s * radius),
4314                _ => glam::Vec3::new(0.0, cs * radius, s * radius),
4315            };
4316            positions.push(p.to_array());
4317        }
4318        strip_lengths.push((positions.len() - start) as u32);
4319    }
4320
4321    let mut item = PolylineItem::default();
4322    item.positions = positions;
4323    item.strip_lengths = strip_lengths;
4324    item.default_color = color;
4325    item.line_width = 2.0;
4326    item
4327}
4328
4329/// Wireframe outline for a clip cylinder (two end-cap circles + longitudinal lines).
4330fn clip_cylinder_outline(
4331    center: [f32; 3],
4332    axis: [f32; 3],
4333    radius: f32,
4334    half_length: f32,
4335    color: [f32; 4],
4336) -> PolylineItem {
4337    let c = glam::Vec3::from(center);
4338    let ax = glam::Vec3::from(axis).normalize();
4339
4340    // Build an orthonormal frame around the axis.
4341    let ref_v = if ax.y.abs() < 0.99 {
4342        glam::Vec3::Y
4343    } else {
4344        glam::Vec3::X
4345    };
4346    let perp_u = ref_v.cross(ax).normalize();
4347    let perp_v = ax.cross(perp_u);
4348
4349    let segs = 32usize;
4350    let long_lines = 8usize;
4351    let cap_verts = segs + 1;
4352    let total_cap = cap_verts * 2 + long_lines * 2;
4353    let mut positions = Vec::with_capacity(total_cap);
4354    let mut strip_lengths = Vec::with_capacity(2 + long_lines);
4355
4356    // Two end-cap circles.
4357    for sign in [-1.0f32, 1.0] {
4358        let cap_center = c + ax * (sign * half_length);
4359        let start = positions.len();
4360        for i in 0..=segs {
4361            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
4362            let (s, cs) = t.sin_cos();
4363            let p = cap_center + perp_u * (cs * radius) + perp_v * (s * radius);
4364            positions.push(p.to_array());
4365        }
4366        strip_lengths.push((positions.len() - start) as u32);
4367    }
4368
4369    // Longitudinal lines connecting the two caps.
4370    for i in 0..long_lines {
4371        let t = i as f32 / long_lines as f32 * std::f32::consts::TAU;
4372        let (s, cs) = t.sin_cos();
4373        let offset = perp_u * (cs * radius) + perp_v * (s * radius);
4374        positions.push((c + ax * (-half_length) + offset).to_array());
4375        positions.push((c + ax *   half_length  + offset).to_array());
4376        strip_lengths.push(2);
4377    }
4378
4379    let mut item = PolylineItem::default();
4380    item.positions = positions;
4381    item.strip_lengths = strip_lengths;
4382    item.default_color = color;
4383    item.line_width = 2.0;
4384    item
4385}
4386
4387// ---------------------------------------------------------------------------
4388// Overlay label helpers
4389// ---------------------------------------------------------------------------
4390
4391/// Project a world-space position to NDC.
4392/// Returns `None` only if the point is behind the camera (`clip.w <= 0`).
4393/// Does NOT reject points outside the [-1,1] viewport box.
4394fn project_to_ndc(
4395    pos: [f32; 3],
4396    view: &glam::Mat4,
4397    proj: &glam::Mat4,
4398) -> Option<[f32; 2]> {
4399    let clip = *proj * *view * glam::Vec3::from(pos).extend(1.0);
4400    if clip.w <= 0.0 { return None; }
4401    Some([clip.x / clip.w, clip.y / clip.w])
4402}
4403
4404/// Convert NDC coordinates to screen pixels (top-left origin).
4405fn ndc_to_screen_px(ndc: [f32; 2], vp_w: f32, vp_h: f32) -> [f32; 2] {
4406    [
4407        (ndc[0] * 0.5 + 0.5) * vp_w,
4408        (1.0 - (ndc[1] * 0.5 + 0.5)) * vp_h,
4409    ]
4410}
4411
4412/// Returns true when the NDC point lies within the viewport square.
4413fn ndc_in_viewport(ndc: [f32; 2]) -> bool {
4414    ndc[0] >= -1.0 && ndc[0] <= 1.0 && ndc[1] >= -1.0 && ndc[1] <= 1.0
4415}
4416
4417/// Clip a line segment [a, b] in NDC to the [-1,1]^2 viewport box
4418/// using the Liang-Barsky algorithm.
4419/// Returns the clipped endpoints, or `None` if the segment is entirely outside.
4420fn clip_line_ndc(a: [f32; 2], b: [f32; 2]) -> Option<([f32; 2], [f32; 2])> {
4421    let dx = b[0] - a[0];
4422    let dy = b[1] - a[1];
4423    let mut t0 = 0.0f32;
4424    let mut t1 = 1.0f32;
4425
4426    // (p, q) pairs for left, right, bottom, top boundaries.
4427    for (p, q) in [
4428        (-dx, a[0] + 1.0),
4429        ( dx, 1.0 - a[0]),
4430        (-dy, a[1] + 1.0),
4431        ( dy, 1.0 - a[1]),
4432    ] {
4433        if p == 0.0 {
4434            if q < 0.0 { return None; }
4435        } else {
4436            let r = q / p;
4437            if p < 0.0 { t0 = t0.max(r); } else { t1 = t1.min(r); }
4438        }
4439    }
4440
4441    if t0 > t1 { return None; }
4442    Some((
4443        [a[0] + t0 * dx, a[1] + t0 * dy],
4444        [a[0] + t1 * dx, a[1] + t1 * dy],
4445    ))
4446}
4447
4448/// Project a world-space position to screen pixels (top-left origin).
4449/// Returns `None` if behind the camera or outside the frustum.
4450fn project_to_screen(
4451    pos: [f32; 3],
4452    view: &glam::Mat4,
4453    proj: &glam::Mat4,
4454    vp_w: f32,
4455    vp_h: f32,
4456) -> Option<[f32; 2]> {
4457    let p = glam::Vec3::from(pos);
4458    let clip = *proj * *view * p.extend(1.0);
4459    if clip.w <= 0.0 {
4460        return None;
4461    }
4462    let ndc_x = clip.x / clip.w;
4463    let ndc_y = clip.y / clip.w;
4464    if ndc_x < -1.0 || ndc_x > 1.0 || ndc_y < -1.0 || ndc_y > 1.0 {
4465        return None;
4466    }
4467    let x = (ndc_x * 0.5 + 0.5) * vp_w;
4468    let y = (1.0 - (ndc_y * 0.5 + 0.5)) * vp_h;
4469    Some([x, y])
4470}
4471
4472/// Convert screen pixel coordinates to NDC.
4473#[inline]
4474fn px_to_ndc(px_x: f32, px_y: f32, vp_w: f32, vp_h: f32) -> [f32; 2] {
4475    [
4476        px_x / vp_w * 2.0 - 1.0,
4477        1.0 - px_y / vp_h * 2.0,
4478    ]
4479}
4480
4481/// Emit a solid-colour quad (6 vertices) in screen pixel coordinates.
4482fn emit_solid_quad(
4483    verts: &mut Vec<crate::resources::OverlayTextVertex>,
4484    x0: f32, y0: f32,
4485    x1: f32, y1: f32,
4486    color: [f32; 4],
4487    vp_w: f32, vp_h: f32,
4488) {
4489    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
4490    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
4491    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
4492    let br = px_to_ndc(x1, y1, vp_w, vp_h);
4493    let uv = [0.0, 0.0];
4494    let tex = 0.0;
4495    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
4496        position: pos, uv, color, use_texture: tex, _pad: 0.0,
4497    };
4498    verts.extend_from_slice(&[v(tl), v(bl), v(tr), v(tr), v(bl), v(br)]);
4499}
4500
4501/// Emit a textured quad (6 vertices) for a glyph in screen pixel coordinates.
4502fn emit_textured_quad(
4503    verts: &mut Vec<crate::resources::OverlayTextVertex>,
4504    x0: f32, y0: f32,
4505    x1: f32, y1: f32,
4506    uv_min: [f32; 2],
4507    uv_max: [f32; 2],
4508    color: [f32; 4],
4509    vp_w: f32, vp_h: f32,
4510) {
4511    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
4512    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
4513    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
4514    let br = px_to_ndc(x1, y1, vp_w, vp_h);
4515    let tex = 1.0;
4516    let v = |pos: [f32; 2], uv: [f32; 2]| crate::resources::OverlayTextVertex {
4517        position: pos, uv, color, use_texture: tex, _pad: 0.0,
4518    };
4519    // UV layout: top-left = uv_min, bottom-right = uv_max.
4520    verts.extend_from_slice(&[
4521        v(tl, uv_min),
4522        v(bl, [uv_min[0], uv_max[1]]),
4523        v(tr, [uv_max[0], uv_min[1]]),
4524        v(tr, [uv_max[0], uv_min[1]]),
4525        v(bl, [uv_min[0], uv_max[1]]),
4526        v(br, uv_max),
4527    ]);
4528}
4529
4530/// Emit a thin screen-space line as a quad (6 vertices).
4531fn emit_line_quad(
4532    verts: &mut Vec<crate::resources::OverlayTextVertex>,
4533    x0: f32, y0: f32,
4534    x1: f32, y1: f32,
4535    thickness: f32,
4536    color: [f32; 4],
4537    vp_w: f32, vp_h: f32,
4538) {
4539    let dx = x1 - x0;
4540    let dy = y1 - y0;
4541    let len = (dx * dx + dy * dy).sqrt();
4542    if len < 0.001 {
4543        return;
4544    }
4545    let half = thickness * 0.5;
4546    let nx = -dy / len * half;
4547    let ny = dx / len * half;
4548
4549    let p0 = px_to_ndc(x0 + nx, y0 + ny, vp_w, vp_h);
4550    let p1 = px_to_ndc(x0 - nx, y0 - ny, vp_w, vp_h);
4551    let p2 = px_to_ndc(x1 + nx, y1 + ny, vp_w, vp_h);
4552    let p3 = px_to_ndc(x1 - nx, y1 - ny, vp_w, vp_h);
4553    let uv = [0.0, 0.0];
4554    let tex = 0.0;
4555    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
4556        position: pos, uv, color, use_texture: tex, _pad: 0.0,
4557    };
4558    verts.extend_from_slice(&[v(p0), v(p1), v(p2), v(p2), v(p1), v(p3)]);
4559}
4560
4561/// Apply an opacity multiplier to a colour's alpha channel.
4562#[inline]
4563fn apply_opacity(color: [f32; 4], opacity: f32) -> [f32; 4] {
4564    [color[0], color[1], color[2], color[3] * opacity]
4565}
4566
4567/// Emit a rounded rectangle as solid quads: one center rect + four edge rects +
4568/// four corner fans.  This is a CPU tessellation approach that avoids shader
4569/// changes.
4570fn emit_rounded_quad(
4571    verts: &mut Vec<crate::resources::OverlayTextVertex>,
4572    x0: f32, y0: f32,
4573    x1: f32, y1: f32,
4574    radius: f32,
4575    color: [f32; 4],
4576    vp_w: f32, vp_h: f32,
4577) {
4578    let w = x1 - x0;
4579    let h = y1 - y0;
4580    let r = radius.min(w * 0.5).min(h * 0.5).max(0.0);
4581
4582    if r < 0.5 {
4583        emit_solid_quad(verts, x0, y0, x1, y1, color, vp_w, vp_h);
4584        return;
4585    }
4586
4587    // Center cross (two rects that cover everything except the corners).
4588    // Horizontal bar (full width, inset top/bottom by r).
4589    emit_solid_quad(verts, x0, y0 + r, x1, y1 - r, color, vp_w, vp_h);
4590    // Top bar (inset left/right by r, top edge).
4591    emit_solid_quad(verts, x0 + r, y0, x1 - r, y0 + r, color, vp_w, vp_h);
4592    // Bottom bar.
4593    emit_solid_quad(verts, x0 + r, y1 - r, x1 - r, y1, color, vp_w, vp_h);
4594
4595    // Four corner fans.
4596    let corners = [
4597        (x0 + r, y0 + r, std::f32::consts::PI, std::f32::consts::FRAC_PI_2 * 3.0),       // top-left
4598        (x1 - r, y0 + r, std::f32::consts::FRAC_PI_2 * 3.0, std::f32::consts::TAU),      // top-right
4599        (x1 - r, y1 - r, 0.0, std::f32::consts::FRAC_PI_2),                               // bottom-right
4600        (x0 + r, y1 - r, std::f32::consts::FRAC_PI_2, std::f32::consts::PI),              // bottom-left
4601    ];
4602    let segments = 6;
4603    let uv = [0.0, 0.0];
4604    let tex = 0.0;
4605    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
4606        position: pos, uv, color, use_texture: tex, _pad: 0.0,
4607    };
4608    for (cx, cy, start, end) in corners {
4609        let center = px_to_ndc(cx, cy, vp_w, vp_h);
4610        for i in 0..segments {
4611            let a0 = start + (end - start) * i as f32 / segments as f32;
4612            let a1 = start + (end - start) * (i + 1) as f32 / segments as f32;
4613            let p0 = px_to_ndc(cx + a0.cos() * r, cy + a0.sin() * r, vp_w, vp_h);
4614            let p1 = px_to_ndc(cx + a1.cos() * r, cy + a1.sin() * r, vp_w, vp_h);
4615            verts.extend_from_slice(&[v(center), v(p0), v(p1)]);
4616        }
4617    }
4618}
4619
4620// ---------------------------------------------------------------------------
4621// Ruler label formatting
4622// ---------------------------------------------------------------------------
4623
4624/// Format a distance value using a caller-supplied format pattern.
4625///
4626/// The pattern may contain one `{...}` placeholder with an optional precision
4627/// specifier, e.g. `"{:.3}"` or `"{:.2} m"`.  Anything outside the braces is
4628/// treated as a literal prefix / suffix.  Unrecognised patterns fall back to
4629/// three decimal places.
4630fn format_ruler_distance(distance: f32, fmt: Option<&str>) -> String {
4631    let pattern = fmt.unwrap_or("{:.3}");
4632    // Find the first `{...}` block.
4633    if let Some(open) = pattern.find('{') {
4634        if let Some(close_rel) = pattern[open..].find('}') {
4635            let close = open + close_rel;
4636            let spec = &pattern[open + 1..close]; // e.g. ":.3" or ""
4637            let prefix = &pattern[..open];
4638            let suffix = &pattern[close + 1..];
4639            let formatted = if let Some(prec_str) = spec.strip_prefix(":.") {
4640                // Strip trailing 'f' for patterns like "{:.3f}".
4641                let prec_str = prec_str.trim_end_matches('f');
4642                if let Ok(prec) = prec_str.parse::<usize>() {
4643                    format!("{distance:.prec$}")
4644                } else {
4645                    format!("{distance:.3}")
4646                }
4647            } else if spec.is_empty() || spec == ":" {
4648                format!("{distance}")
4649            } else {
4650                format!("{distance:.3}")
4651            };
4652            return format!("{prefix}{formatted}{suffix}");
4653        }
4654    }
4655    format!("{distance:.3}")
4656}