Skip to main content

viewport_lib/renderer/
prepare.rs

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