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        // Resolve scene items from the SurfaceSubmission seam.
38        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
39            SurfaceSubmission::Flat(items) => items,
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                if resources
407                    .mesh_store
408                    .get(item.mesh_id)
409                    .is_none()
410                {
411                    tracing::warn!(
412                        mesh_index = item.mesh_id.index(),
413                        "scene item mesh_index invalid, skipping"
414                    );
415                    continue;
416                };
417                let m = &item.material;
418                // Compute scalar attribute range.
419                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
420                    let range = item
421                        .scalar_range
422                        .or_else(|| {
423                            resources
424                                .mesh_store
425                                .get(item.mesh_id)
426                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
427                        })
428                        .unwrap_or((0.0, 1.0));
429                    (1u32, range.0, range.1)
430                } else {
431                    (0u32, 0.0, 1.0)
432                };
433                let obj_uniform = ObjectUniform {
434                    model: item.model,
435                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
436                    selected: if item.selected { 1 } else { 0 },
437                    wireframe: if frame.viewport.wireframe_mode || item.render_as_wireframe { 1 } else { 0 },
438                    ambient: m.ambient,
439                    diffuse: m.diffuse,
440                    specular: m.specular,
441                    shininess: m.shininess,
442                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
443                    use_pbr: if m.use_pbr { 1 } else { 0 },
444                    metallic: m.metallic,
445                    roughness: m.roughness,
446                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
447                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
448                    has_attribute: has_attr,
449                    scalar_min: s_min,
450                    scalar_max: s_max,
451                    _pad_scalar: 0,
452                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
453                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
454                    use_matcap: if m.matcap_id.is_some() { 1 } else { 0 },
455                    matcap_blendable: m.matcap_id.map_or(0, |id| if id.blendable { 1 } else { 0 }),
456                    _pad2: 0,
457                    use_face_color: u32::from(item.active_attribute.as_ref().map_or(false, |a| {
458                        a.kind == crate::resources::AttributeKind::FaceColor
459                    })),
460                    uv_vis_mode: m.param_vis.map_or(0, |pv| pv.mode as u32),
461                    uv_vis_scale: m.param_vis.map_or(8.0, |pv| pv.scale),
462                    backface_policy: match m.backface_policy {
463                        crate::scene::material::BackfacePolicy::Cull => 0,
464                        crate::scene::material::BackfacePolicy::Identical => 1,
465                        crate::scene::material::BackfacePolicy::DifferentColor(_) => 2,
466                        crate::scene::material::BackfacePolicy::Tint(_) => 3,
467                        crate::scene::material::BackfacePolicy::Pattern { pattern, .. } => {
468                            4 + pattern as u32
469                        }
470                    },
471                    backface_color: match m.backface_policy {
472                        crate::scene::material::BackfacePolicy::DifferentColor(c) => {
473                            [c[0], c[1], c[2], 1.0]
474                        }
475                        crate::scene::material::BackfacePolicy::Tint(factor) => {
476                            [factor, 0.0, 0.0, 1.0]
477                        }
478                        crate::scene::material::BackfacePolicy::Pattern { color, .. } => {
479                            [color[0], color[1], color[2], 1.0]
480                        }
481                        _ => [0.0; 4],
482                    },
483                };
484
485                let normal_obj_uniform = ObjectUniform {
486                    model: item.model,
487                    color: [1.0, 1.0, 1.0, 1.0],
488                    selected: 0,
489                    wireframe: 0,
490                    ambient: 0.15,
491                    diffuse: 0.75,
492                    specular: 0.4,
493                    shininess: 32.0,
494                    has_texture: 0,
495                    use_pbr: 0,
496                    metallic: 0.0,
497                    roughness: 0.5,
498                    has_normal_map: 0,
499                    has_ao_map: 0,
500                    has_attribute: 0,
501                    scalar_min: 0.0,
502                    scalar_max: 1.0,
503                    _pad_scalar: 0,
504                    nan_color: [0.0; 4],
505                    use_nan_color: 0,
506                    use_matcap: 0,
507                    matcap_blendable: 0,
508                    _pad2: 0,
509                    use_face_color: 0,
510                    uv_vis_mode: 0,
511                    uv_vis_scale: 8.0,
512                    backface_policy: 0,
513                    backface_color: [0.0; 4],
514                };
515
516                // Write uniform data : use get() to read buffer references, then drop.
517                {
518                    let mesh = resources
519                        .mesh_store
520                        .get(item.mesh_id)
521                        .unwrap();
522                    queue.write_buffer(
523                        &mesh.object_uniform_buf,
524                        0,
525                        bytemuck::cast_slice(&[obj_uniform]),
526                    );
527                    queue.write_buffer(
528                        &mesh.normal_uniform_buf,
529                        0,
530                        bytemuck::cast_slice(&[normal_obj_uniform]),
531                    );
532                } // mesh borrow dropped here
533
534                // Rebuild the object bind group if material/attribute/LUT/matcap changed.
535                resources.update_mesh_texture_bind_group(
536                    device,
537                    item.mesh_id,
538                    item.material.texture_id,
539                    item.material.normal_map_id,
540                    item.material.ao_map_id,
541                    item.colormap_id,
542                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
543                    item.material.matcap_id,
544                );
545            }
546        }
547
548        if self.use_instancing {
549            resources.ensure_instanced_pipelines(device);
550
551            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
552            // Phase 2: wireframe_mode removed from cache key : wireframe rendering
553            // uses the per-object wireframe_pipeline, not the instanced path, so
554            // instance data is now viewport-agnostic.
555            let cache_valid = frame.scene.generation == self.last_scene_generation
556                && frame.interaction.selection_generation == self.last_selection_generation
557                && scene_items.len() == self.last_scene_items_count;
558
559            if !cache_valid {
560                // Cache miss : rebuild batches and upload instance data.
561                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
562                    .iter()
563                    .filter(|item| {
564                        item.visible
565                            && item.active_attribute.is_none()
566                            && !item.material.is_two_sided()
567                            && item.material.matcap_id.is_none()
568                            && item.material.param_vis.is_none()
569                            && resources
570                                .mesh_store
571                                .get(item.mesh_id)
572                                .is_some()
573                    })
574                    .collect();
575
576                sorted_items.sort_unstable_by_key(|item| {
577                    (
578                        item.mesh_id.index(),
579                        item.material.texture_id,
580                        item.material.normal_map_id,
581                        item.material.ao_map_id,
582                    )
583                });
584
585                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
586                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
587
588                if !sorted_items.is_empty() {
589                    let mut batch_start = 0usize;
590                    for i in 1..=sorted_items.len() {
591                        let at_end = i == sorted_items.len();
592                        let key_changed = !at_end && {
593                            let a = sorted_items[batch_start];
594                            let b = sorted_items[i];
595                            a.mesh_id != b.mesh_id
596                                || a.material.texture_id != b.material.texture_id
597                                || a.material.normal_map_id != b.material.normal_map_id
598                                || a.material.ao_map_id != b.material.ao_map_id
599                        };
600
601                        if at_end || key_changed {
602                            let batch_items = &sorted_items[batch_start..i];
603                            let rep = batch_items[0];
604                            let instance_offset = all_instances.len() as u32;
605                            let is_transparent = rep.material.opacity < 1.0;
606
607                            for item in batch_items {
608                                let m = &item.material;
609                                all_instances.push(InstanceData {
610                                    model: item.model,
611                                    color: [
612                                        m.base_color[0],
613                                        m.base_color[1],
614                                        m.base_color[2],
615                                        m.opacity,
616                                    ],
617                                    selected: if item.selected { 1 } else { 0 },
618                                    wireframe: 0, // Phase 2: always 0 : wireframe uses per-object pipeline
619                                    ambient: m.ambient,
620                                    diffuse: m.diffuse,
621                                    specular: m.specular,
622                                    shininess: m.shininess,
623                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
624                                    use_pbr: if m.use_pbr { 1 } else { 0 },
625                                    metallic: m.metallic,
626                                    roughness: m.roughness,
627                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
628                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
629                                });
630                            }
631
632                            instanced_batches.push(InstancedBatch {
633                                mesh_id: rep.mesh_id,
634                                texture_id: rep.material.texture_id,
635                                normal_map_id: rep.material.normal_map_id,
636                                ao_map_id: rep.material.ao_map_id,
637                                instance_offset,
638                                instance_count: batch_items.len() as u32,
639                                is_transparent,
640                            });
641
642                            batch_start = i;
643                        }
644                    }
645                }
646
647                self.cached_instance_data = all_instances;
648                self.cached_instanced_batches = instanced_batches;
649
650                resources.upload_instance_data(device, queue, &self.cached_instance_data);
651
652                self.instanced_batches = self.cached_instanced_batches.clone();
653
654                self.last_scene_generation = frame.scene.generation;
655                self.last_selection_generation = frame.interaction.selection_generation;
656                self.last_scene_items_count = scene_items.len();
657
658                for batch in &self.instanced_batches {
659                    resources.get_instance_bind_group(
660                        device,
661                        batch.texture_id,
662                        batch.normal_map_id,
663                        batch.ao_map_id,
664                    );
665                }
666            } else {
667                for batch in &self.instanced_batches {
668                    resources.get_instance_bind_group(
669                        device,
670                        batch.texture_id,
671                        batch.normal_map_id,
672                        batch.ao_map_id,
673                    );
674                }
675            }
676        }
677
678        // ------------------------------------------------------------------
679        // SciVis Phase B : point cloud and glyph GPU data upload.
680        // ------------------------------------------------------------------
681        self.point_cloud_gpu_data.clear();
682        if !frame.scene.point_clouds.is_empty() {
683            resources.ensure_point_cloud_pipeline(device);
684            for item in &frame.scene.point_clouds {
685                if item.positions.is_empty() {
686                    continue;
687                }
688                let gpu_data = resources.upload_point_cloud(device, queue, item);
689                self.point_cloud_gpu_data.push(gpu_data);
690            }
691        }
692
693        self.glyph_gpu_data.clear();
694        if !frame.scene.glyphs.is_empty() {
695            resources.ensure_glyph_pipeline(device);
696            for item in &frame.scene.glyphs {
697                if item.positions.is_empty() || item.vectors.is_empty() {
698                    continue;
699                }
700                let gpu_data = resources.upload_glyph_set(device, queue, item);
701                self.glyph_gpu_data.push(gpu_data);
702            }
703        }
704
705        // ------------------------------------------------------------------
706        // SciVis Phase M8 : polyline GPU data upload.
707        // ------------------------------------------------------------------
708        self.polyline_gpu_data.clear();
709        let vp_size = frame.camera.viewport_size;
710        if !frame.scene.polylines.is_empty() {
711            resources.ensure_polyline_pipeline(device);
712            for item in &frame.scene.polylines {
713                if item.positions.is_empty() {
714                    continue;
715                }
716                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
717                self.polyline_gpu_data.push(gpu_data);
718
719                // Phase 11: auto-generate GlyphItems for node/edge vector quantities.
720                if !item.node_vectors.is_empty() {
721                    resources.ensure_glyph_pipeline(device);
722                    let g = crate::quantities::polyline_node_vectors_to_glyphs(item);
723                    if !g.positions.is_empty() {
724                        let gd = resources.upload_glyph_set(device, queue, &g);
725                        self.glyph_gpu_data.push(gd);
726                    }
727                }
728                if !item.edge_vectors.is_empty() {
729                    resources.ensure_glyph_pipeline(device);
730                    let g = crate::quantities::polyline_edge_vectors_to_glyphs(item);
731                    if !g.positions.is_empty() {
732                        let gd = resources.upload_glyph_set(device, queue, &g);
733                        self.glyph_gpu_data.push(gd);
734                    }
735                }
736            }
737        }
738
739        // ------------------------------------------------------------------
740        // SciVis Phase L : isoline extraction and upload via polyline pipeline.
741        // ------------------------------------------------------------------
742        if !frame.scene.isolines.is_empty() {
743            resources.ensure_polyline_pipeline(device);
744            for item in &frame.scene.isolines {
745                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
746                    continue;
747                }
748                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
749                if positions.is_empty() {
750                    continue;
751                }
752                let polyline = PolylineItem {
753                    positions,
754                    scalars: Vec::new(),
755                    strip_lengths,
756                    scalar_range: None,
757                    colormap_id: None,
758                    default_color: item.color,
759                    line_width: item.line_width,
760                    id: 0,
761                    ..Default::default()
762                };
763                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
764                self.polyline_gpu_data.push(gpu_data);
765            }
766        }
767
768        // ------------------------------------------------------------------
769        // Phase 10A : camera frustum wireframes (converted to polylines).
770        // ------------------------------------------------------------------
771        if !frame.scene.camera_frustums.is_empty() {
772            resources.ensure_polyline_pipeline(device);
773            for item in &frame.scene.camera_frustums {
774                let polyline = item.to_polyline();
775                if !polyline.positions.is_empty() {
776                    let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
777                    self.polyline_gpu_data.push(gpu_data);
778                }
779            }
780        }
781
782        // ------------------------------------------------------------------
783        // Phase 16 : GPU implicit surface items.
784        // ------------------------------------------------------------------
785        self.implicit_gpu_data.clear();
786        if !frame.scene.gpu_implicit.is_empty() {
787            resources.ensure_implicit_pipeline(device);
788            for item in &frame.scene.gpu_implicit {
789                if item.primitives.is_empty() {
790                    continue;
791                }
792                let gpu = resources.upload_implicit_item(device, item);
793                self.implicit_gpu_data.push(gpu);
794            }
795        }
796
797        // ------------------------------------------------------------------
798        // Phase 17 : GPU marching cubes compute dispatch.
799        // ------------------------------------------------------------------
800        self.mc_gpu_data.clear();
801        if !frame.scene.gpu_mc_jobs.is_empty() {
802            resources.ensure_mc_pipelines(device);
803            self.mc_gpu_data =
804                resources.run_mc_jobs(device, queue, &frame.scene.gpu_mc_jobs);
805        }
806
807        // ------------------------------------------------------------------
808        // Phase 10B : screen-space image overlays.
809        // ------------------------------------------------------------------
810        self.screen_image_gpu_data.clear();
811        if !frame.scene.screen_images.is_empty() {
812            resources.ensure_screen_image_pipeline(device);
813            // Phase 12: ensure dc pipeline if any item carries depth data.
814            if frame.scene.screen_images.iter().any(|i| i.depth.is_some()) {
815                resources.ensure_screen_image_dc_pipeline(device);
816            }
817            let vp_w = vp_size[0];
818            let vp_h = vp_size[1];
819            for item in &frame.scene.screen_images {
820                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
821                    continue;
822                }
823                let gpu = resources.upload_screen_image(device, queue, item, vp_w, vp_h);
824                self.screen_image_gpu_data.push(gpu);
825            }
826        }
827
828        // ------------------------------------------------------------------
829        // Phase 7 : overlay image overlays (OverlayFrame).
830        // ------------------------------------------------------------------
831        self.overlay_image_gpu_data.clear();
832        if !frame.overlays.images.is_empty() {
833            resources.ensure_screen_image_pipeline(device);
834            let vp_w = vp_size[0];
835            let vp_h = vp_size[1];
836            for item in &frame.overlays.images {
837                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
838                    continue;
839                }
840                let gpu = resources.upload_overlay_image(device, queue, item, vp_w, vp_h);
841                self.overlay_image_gpu_data.push(gpu);
842            }
843        }
844
845        // ------------------------------------------------------------------
846        // SciVis Phase M : streamtube GPU data upload.
847        // ------------------------------------------------------------------
848        self.streamtube_gpu_data.clear();
849        if !frame.scene.streamtube_items.is_empty() {
850            resources.ensure_streamtube_pipeline(device);
851            for item in &frame.scene.streamtube_items {
852                if item.positions.is_empty() || item.strip_lengths.is_empty() {
853                    continue;
854                }
855                let gpu_data = resources.upload_streamtube(device, queue, item);
856                if gpu_data.index_count > 0 {
857                    self.streamtube_gpu_data.push(gpu_data);
858                }
859            }
860        }
861
862        // ------------------------------------------------------------------
863        // SciVis Phase D : volume GPU data upload.
864        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
865        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
866        // ------------------------------------------------------------------
867        self.volume_gpu_data.clear();
868        if !frame.scene.volumes.is_empty() {
869            resources.ensure_volume_pipeline(device);
870            // Extract ClipPlane structs from clip_objects for volume cap fill support.
871            let clip_planes_for_vol: Vec<crate::renderer::types::ClipPlane> = frame
872                .effects
873                .clip_objects
874                .iter()
875                .filter(|o| o.enabled)
876                .filter_map(|o| {
877                    if let ClipShape::Plane {
878                        normal,
879                        distance,
880                        cap_color,
881                    } = o.shape
882                    {
883                        Some(crate::renderer::types::ClipPlane {
884                            normal,
885                            distance,
886                            enabled: true,
887                            cap_color,
888                        })
889                    } else {
890                        None
891                    }
892                })
893                .collect();
894            // Phase 5: under budget pressure with allow_volume_quality_reduction, double the
895            // step size (half the sample count) to reduce GPU raymarch cost.
896            let vol_step_multiplier =
897                if self.last_stats.missed_budget
898                    && self.performance_policy.allow_volume_quality_reduction
899                {
900                    2.0_f32
901                } else {
902                    1.0_f32
903                };
904            for item in &frame.scene.volumes {
905                let gpu = resources.upload_volume_frame(
906                    device,
907                    queue,
908                    item,
909                    &clip_planes_for_vol,
910                    vol_step_multiplier,
911                );
912                self.volume_gpu_data.push(gpu);
913            }
914        }
915
916        // -- Frame stats --
917        {
918            let total = scene_items.len() as u32;
919            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
920            let mut draw_calls = 0u32;
921            let mut triangles = 0u64;
922            let instanced_batch_count = if self.use_instancing {
923                self.instanced_batches.len() as u32
924            } else {
925                0
926            };
927
928            if self.use_instancing {
929                for batch in &self.instanced_batches {
930                    if let Some(mesh) = resources
931                        .mesh_store
932                        .get(batch.mesh_id)
933                    {
934                        draw_calls += 1;
935                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
936                    }
937                }
938            } else {
939                for item in scene_items {
940                    if !item.visible {
941                        continue;
942                    }
943                    if let Some(mesh) = resources
944                        .mesh_store
945                        .get(item.mesh_id)
946                    {
947                        draw_calls += 1;
948                        triangles += (mesh.index_count / 3) as u64;
949                    }
950                }
951            }
952
953            self.last_stats = crate::renderer::stats::FrameStats {
954                total_objects: total,
955                visible_objects: visible,
956                culled_objects: total.saturating_sub(visible),
957                draw_calls,
958                instanced_batches: instanced_batch_count,
959                triangles_submitted: triangles,
960                shadow_draw_calls: 0, // Updated below in shadow pass.
961                ..self.last_stats
962            };
963        }
964
965        // ------------------------------------------------------------------
966        // Shadow depth pass : CSM: render each cascade into its atlas tile.
967        // Phase 5: skip the pass entirely when over budget and shadow reduction is allowed.
968        // ------------------------------------------------------------------
969        let skip_shadows = self.last_stats.missed_budget
970            && self.performance_policy.allow_shadow_reduction;
971        if lighting.shadows_enabled && !scene_items.is_empty() && !skip_shadows {
972            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
973                label: Some("shadow_pass_encoder"),
974            });
975            {
976                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
977                    label: Some("shadow_pass"),
978                    color_attachments: &[],
979                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
980                        view: &resources.shadow_map_view,
981                        depth_ops: Some(wgpu::Operations {
982                            load: wgpu::LoadOp::Clear(1.0),
983                            store: wgpu::StoreOp::Store,
984                        }),
985                        stencil_ops: None,
986                    }),
987                    timestamp_writes: None,
988                    occlusion_query_set: None,
989                });
990
991                let mut shadow_draws = 0u32;
992                let tile_px = tile_size as f32;
993
994                if self.use_instancing {
995                    if let (Some(pipeline), Some(instance_bg)) = (
996                        &resources.shadow_instanced_pipeline,
997                        self.instanced_batches.first().and_then(|b| {
998                            resources.instance_bind_groups.get(&(
999                                b.texture_id.unwrap_or(u64::MAX),
1000                                b.normal_map_id.unwrap_or(u64::MAX),
1001                                b.ao_map_id.unwrap_or(u64::MAX),
1002                            ))
1003                        }),
1004                    ) {
1005                        for cascade in 0..effective_cascade_count {
1006                            let tile_col = (cascade % 2) as f32;
1007                            let tile_row = (cascade / 2) as f32;
1008                            shadow_pass.set_viewport(
1009                                tile_col * tile_px,
1010                                tile_row * tile_px,
1011                                tile_px,
1012                                tile_px,
1013                                0.0,
1014                                1.0,
1015                            );
1016                            shadow_pass.set_scissor_rect(
1017                                (tile_col * tile_px) as u32,
1018                                (tile_row * tile_px) as u32,
1019                                tile_size,
1020                                tile_size,
1021                            );
1022
1023                            shadow_pass.set_pipeline(pipeline);
1024
1025                            queue.write_buffer(
1026                                resources.shadow_instanced_cascade_bufs[cascade]
1027                                    .as_ref()
1028                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1029                                0,
1030                                bytemuck::cast_slice(
1031                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1032                                ),
1033                            );
1034
1035                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
1036                                .as_ref()
1037                                .expect("shadow_instanced_cascade_bgs not allocated");
1038                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1039                            shadow_pass.set_bind_group(1, instance_bg, &[]);
1040
1041                            for batch in &self.instanced_batches {
1042                                if batch.is_transparent {
1043                                    continue;
1044                                }
1045                                let Some(mesh) = resources
1046                                    .mesh_store
1047                                    .get(batch.mesh_id)
1048                                else {
1049                                    continue;
1050                                };
1051                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1052                                shadow_pass.set_index_buffer(
1053                                    mesh.index_buffer.slice(..),
1054                                    wgpu::IndexFormat::Uint32,
1055                                );
1056                                shadow_pass.draw_indexed(
1057                                    0..mesh.index_count,
1058                                    0,
1059                                    batch.instance_offset
1060                                        ..batch.instance_offset + batch.instance_count,
1061                                );
1062                                shadow_draws += 1;
1063                            }
1064                        }
1065                    }
1066                } else {
1067                    for cascade in 0..effective_cascade_count {
1068                        let tile_col = (cascade % 2) as f32;
1069                        let tile_row = (cascade / 2) as f32;
1070                        shadow_pass.set_viewport(
1071                            tile_col * tile_px,
1072                            tile_row * tile_px,
1073                            tile_px,
1074                            tile_px,
1075                            0.0,
1076                            1.0,
1077                        );
1078                        shadow_pass.set_scissor_rect(
1079                            (tile_col * tile_px) as u32,
1080                            (tile_row * tile_px) as u32,
1081                            tile_size,
1082                            tile_size,
1083                        );
1084
1085                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
1086                        shadow_pass.set_bind_group(
1087                            0,
1088                            &resources.shadow_bind_group,
1089                            &[cascade as u32 * 256],
1090                        );
1091
1092                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
1093                            &cascade_view_projs[cascade],
1094                        );
1095
1096                        for item in scene_items.iter() {
1097                            if !item.visible {
1098                                continue;
1099                            }
1100                            if item.material.opacity < 1.0 {
1101                                continue;
1102                            }
1103                            let Some(mesh) = resources
1104                                .mesh_store
1105                                .get(item.mesh_id)
1106                            else {
1107                                continue;
1108                            };
1109
1110                            let world_aabb = mesh
1111                                .aabb
1112                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1113                            if cascade_frustum.cull_aabb(&world_aabb) {
1114                                continue;
1115                            }
1116
1117                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1118                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1119                            shadow_pass.set_index_buffer(
1120                                mesh.index_buffer.slice(..),
1121                                wgpu::IndexFormat::Uint32,
1122                            );
1123                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1124                            shadow_draws += 1;
1125                        }
1126                    }
1127                }
1128                drop(shadow_pass);
1129                self.last_stats.shadow_draw_calls = shadow_draws;
1130            }
1131            queue.submit(std::iter::once(encoder.finish()));
1132        }
1133    }
1134
1135    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
1136    ///
1137    /// Call once per viewport per frame, after `prepare_scene_internal`.
1138    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
1139    pub(super) fn prepare_viewport_internal(
1140        &mut self,
1141        device: &wgpu::Device,
1142        queue: &wgpu::Queue,
1143        frame: &FrameData,
1144        viewport_fx: &ViewportEffects<'_>,
1145    ) {
1146        // Ensure a per-viewport camera slot exists for this viewport index.
1147        // Must happen before the `resources` borrow below.
1148        self.ensure_viewport_slot(device, frame.camera.viewport_index);
1149
1150        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1151            SurfaceSubmission::Flat(items) => items,
1152        };
1153
1154        // Capture before the resources mutable borrow so it's accessible inside the block.
1155        let gp_cascade0_mat = self.last_cascade0_shadow_mat.to_cols_array_2d();
1156
1157        {
1158            let resources = &mut self.resources;
1159
1160            // Upload clip planes + clip volume uniforms from clip_objects.
1161            {
1162                let mut planes = [[0.0f32; 4]; 6];
1163                let mut count = 0u32;
1164                let mut clip_vol_uniform: ClipVolumeUniform = bytemuck::Zeroable::zeroed(); // volume_type=0
1165
1166                for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1167                    match obj.shape {
1168                        ClipShape::Plane {
1169                            normal, distance, ..
1170                        } if count < 6 => {
1171                            planes[count as usize] = [normal[0], normal[1], normal[2], distance];
1172                            count += 1;
1173                        }
1174                        ClipShape::Box {
1175                            center,
1176                            half_extents,
1177                            orientation,
1178                        } if clip_vol_uniform.volume_type == 0 => {
1179                            clip_vol_uniform.volume_type = 2;
1180                            clip_vol_uniform.box_center = center;
1181                            clip_vol_uniform.box_half_extents = half_extents;
1182                            clip_vol_uniform.box_col0 = orientation[0];
1183                            clip_vol_uniform.box_col1 = orientation[1];
1184                            clip_vol_uniform.box_col2 = orientation[2];
1185                        }
1186                        ClipShape::Sphere { center, radius }
1187                            if clip_vol_uniform.volume_type == 0 =>
1188                        {
1189                            clip_vol_uniform.volume_type = 3;
1190                            clip_vol_uniform.sphere_center = center;
1191                            clip_vol_uniform.sphere_radius = radius;
1192                        }
1193                        _ => {}
1194                    }
1195                }
1196
1197                let clip_uniform = ClipPlanesUniform {
1198                    planes,
1199                    count,
1200                    _pad0: 0,
1201                    viewport_width: frame.camera.viewport_size[0].max(1.0),
1202                    viewport_height: frame.camera.viewport_size[1].max(1.0),
1203                };
1204                // Write to per-viewport slot buffer.
1205                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1206                    queue.write_buffer(
1207                        &slot.clip_planes_buf,
1208                        0,
1209                        bytemuck::cast_slice(&[clip_uniform]),
1210                    );
1211                    queue.write_buffer(
1212                        &slot.clip_volume_buf,
1213                        0,
1214                        bytemuck::cast_slice(&[clip_vol_uniform]),
1215                    );
1216                }
1217                // Also write to shared buffers for legacy single-viewport callers.
1218                queue.write_buffer(
1219                    &resources.clip_planes_uniform_buf,
1220                    0,
1221                    bytemuck::cast_slice(&[clip_uniform]),
1222                );
1223                queue.write_buffer(
1224                    &resources.clip_volume_uniform_buf,
1225                    0,
1226                    bytemuck::cast_slice(&[clip_vol_uniform]),
1227                );
1228            }
1229
1230            // Upload camera uniform to per-viewport slot buffer.
1231            let camera_uniform = frame.camera.render_camera.camera_uniform();
1232            // Write to shared buffer for legacy single-viewport callers.
1233            queue.write_buffer(
1234                &resources.camera_uniform_buf,
1235                0,
1236                bytemuck::cast_slice(&[camera_uniform]),
1237            );
1238            // Write to the per-viewport slot buffer.
1239            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1240                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1241            }
1242
1243            // Upload grid uniform (full-screen analytical shader : no vertex buffers needed).
1244            if frame.viewport.show_grid {
1245                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1246                if !eye.is_finite() {
1247                    tracing::warn!(
1248                        eye_x = eye.x,
1249                        eye_y = eye.y,
1250                        eye_z = eye.z,
1251                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1252                    );
1253                } else {
1254                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1255
1256                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1257                        (frame.viewport.grid_cell_size, 1.0_f32)
1258                    } else {
1259                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1260                        let world_per_pixel =
1261                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1262                                / frame.camera.viewport_size[1].max(1.0);
1263                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1264                        let mut s = 1.0_f32;
1265                        let mut iters = 0u32;
1266                        while s < target {
1267                            s *= 10.0;
1268                            iters += 1;
1269                        }
1270                        let ratio = (target / s).clamp(0.0, 1.0);
1271                        let fade = if ratio < 0.5 {
1272                            1.0_f32
1273                        } else {
1274                            let t = (ratio - 0.5) * 2.0;
1275                            1.0 - t * t * (3.0 - 2.0 * t)
1276                        };
1277                        tracing::debug!(
1278                            eye_z = eye.z,
1279                            vertical_depth,
1280                            world_per_pixel,
1281                            target,
1282                            spacing = s,
1283                            lod_iters = iters,
1284                            ratio,
1285                            minor_fade = fade,
1286                            "grid LOD"
1287                        );
1288                        (s, fade)
1289                    };
1290
1291                    let spacing_major = spacing * 10.0;
1292                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1293                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1294                    tracing::debug!(
1295                        spacing_minor = spacing,
1296                        spacing_major,
1297                        snap_x,
1298                        snap_y,
1299                        eye_x = eye.x,
1300                        eye_y = eye.y,
1301                        eye_z = eye.z,
1302                        "grid snap"
1303                    );
1304
1305                    let orient = frame.camera.render_camera.orientation;
1306                    let right = orient * glam::Vec3::X;
1307                    let up = orient * glam::Vec3::Y;
1308                    let back = orient * glam::Vec3::Z;
1309                    let cam_to_world = [
1310                        [right.x, right.y, right.z, 0.0_f32],
1311                        [up.x, up.y, up.z, 0.0_f32],
1312                        [back.x, back.y, back.z, 0.0_f32],
1313                    ];
1314                    let aspect =
1315                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1316                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1317
1318                    let uniform = GridUniform {
1319                        view_proj: view_proj_mat,
1320                        cam_to_world,
1321                        tan_half_fov,
1322                        aspect,
1323                        _pad_ivp: [0.0; 2],
1324                        eye_pos: frame.camera.render_camera.eye_position,
1325                        grid_z: frame.viewport.grid_z,
1326                        spacing_minor: spacing,
1327                        spacing_major,
1328                        snap_origin: [snap_x, snap_y],
1329                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1330                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1331                    };
1332                    // Write to per-viewport slot buffer.
1333                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1334                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1335                    }
1336                    // Also write to shared buffer for legacy callers.
1337                    queue.write_buffer(
1338                        &resources.grid_uniform_buf,
1339                        0,
1340                        bytemuck::cast_slice(&[uniform]),
1341                    );
1342                }
1343            }
1344            // ------------------------------------------------------------------
1345            // Ground plane uniform upload.
1346            // ------------------------------------------------------------------
1347            {
1348                let gp = &viewport_fx.ground_plane;
1349                let mode_u32: u32 = match gp.mode {
1350                    crate::renderer::types::GroundPlaneMode::None => 0,
1351                    crate::renderer::types::GroundPlaneMode::ShadowOnly => 1,
1352                    crate::renderer::types::GroundPlaneMode::Tile => 2,
1353                    crate::renderer::types::GroundPlaneMode::SolidColor => 3,
1354                };
1355                let orient = frame.camera.render_camera.orientation;
1356                let right = orient * glam::Vec3::X;
1357                let up = orient * glam::Vec3::Y;
1358                let back = orient * glam::Vec3::Z;
1359                let aspect = frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1360                let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1361                let vp = frame.camera.render_camera.view_proj().to_cols_array_2d();
1362                let gp_uniform = crate::resources::GroundPlaneUniform {
1363                    view_proj: vp,
1364                    cam_right: [right.x, right.y, right.z, 0.0],
1365                    cam_up: [up.x, up.y, up.z, 0.0],
1366                    cam_back: [back.x, back.y, back.z, 0.0],
1367                    eye_pos: frame.camera.render_camera.eye_position,
1368                    height: gp.height,
1369                    color: gp.color,
1370                    shadow_color: gp.shadow_color,
1371                    light_vp: gp_cascade0_mat,
1372                    tan_half_fov,
1373                    aspect,
1374                    tile_size: gp.tile_size,
1375                    shadow_bias: 0.002,
1376                    mode: mode_u32,
1377                    shadow_opacity: gp.shadow_opacity,
1378                    _pad: [0.0; 2],
1379                };
1380                queue.write_buffer(
1381                    &resources.ground_plane_uniform_buf,
1382                    0,
1383                    bytemuck::cast_slice(&[gp_uniform]),
1384                );
1385            }
1386        } // `resources` mutable borrow dropped here.
1387
1388        // ------------------------------------------------------------------
1389        // Build per-viewport interaction state into local variables.
1390        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1391        // the slot borrow that follows.
1392        // ------------------------------------------------------------------
1393
1394        let vp_idx = frame.camera.viewport_index;
1395
1396        // Outline mask buffers for selected objects (one per selected object).
1397        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1398        if frame.interaction.outline_selected {
1399            let resources = &self.resources;
1400            for item in scene_items {
1401                if !item.visible || !item.selected {
1402                    continue;
1403                }
1404                let uniform = OutlineUniform {
1405                    model: item.model,
1406                    color: [0.0; 4], // unused by mask shader
1407                    pixel_offset: 0.0,
1408                    _pad: [0.0; 3],
1409                };
1410                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1411                    label: Some("outline_mask_uniform_buf"),
1412                    size: std::mem::size_of::<OutlineUniform>() as u64,
1413                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1414                    mapped_at_creation: false,
1415                });
1416                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1417                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1418                    label: Some("outline_mask_object_bg"),
1419                    layout: &resources.outline_bind_group_layout,
1420                    entries: &[wgpu::BindGroupEntry {
1421                        binding: 0,
1422                        resource: buf.as_entire_binding(),
1423                    }],
1424                });
1425                outline_object_buffers.push(OutlineObjectBuffers {
1426                    mesh_id: item.mesh_id,
1427                    two_sided: item.material.is_two_sided(),
1428                    _mask_uniform_buf: buf,
1429                    mask_bind_group: bg,
1430                });
1431            }
1432        }
1433
1434        // X-ray buffers for selected objects.
1435        let mut xray_object_buffers: Vec<(crate::resources::mesh_store::MeshId, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1436        if frame.interaction.xray_selected {
1437            let resources = &self.resources;
1438            for item in scene_items {
1439                if !item.visible || !item.selected {
1440                    continue;
1441                }
1442                let uniform = OutlineUniform {
1443                    model: item.model,
1444                    color: frame.interaction.xray_color,
1445                    pixel_offset: 0.0,
1446                    _pad: [0.0; 3],
1447                };
1448                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1449                    label: Some("xray_uniform_buf"),
1450                    size: std::mem::size_of::<OutlineUniform>() as u64,
1451                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1452                    mapped_at_creation: false,
1453                });
1454                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1455                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1456                    label: Some("xray_object_bg"),
1457                    layout: &resources.outline_bind_group_layout,
1458                    entries: &[wgpu::BindGroupEntry {
1459                        binding: 0,
1460                        resource: buf.as_entire_binding(),
1461                    }],
1462                });
1463                xray_object_buffers.push((item.mesh_id, buf, bg));
1464            }
1465        }
1466
1467        // Constraint guide lines.
1468        let mut constraint_line_buffers = Vec::new();
1469        for overlay in &frame.interaction.constraint_overlays {
1470            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
1471        }
1472
1473        // Clip plane overlays : generated automatically from clip_objects with a color set.
1474        let mut clip_plane_fill_buffers = Vec::new();
1475        let mut clip_plane_line_buffers = Vec::new();
1476        for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1477            let Some(base_color) = obj.color else {
1478                continue;
1479            };
1480            if let ClipShape::Plane {
1481                normal, distance, ..
1482            } = obj.shape
1483            {
1484                let n = glam::Vec3::from(normal);
1485                // Shader plane equation: dot(p, n) + distance = 0, so the plane
1486                // sits at -n * distance from the origin.
1487                let center = n * (-distance);
1488                let active = obj.active;
1489                let hovered = obj.hovered || active;
1490
1491                let fill_color = if active {
1492                    [
1493                        base_color[0] * 0.5,
1494                        base_color[1] * 0.5,
1495                        base_color[2] * 0.5,
1496                        base_color[3] * 0.5,
1497                    ]
1498                } else if hovered {
1499                    [
1500                        base_color[0] * 0.8,
1501                        base_color[1] * 0.8,
1502                        base_color[2] * 0.8,
1503                        base_color[3] * 0.6,
1504                    ]
1505                } else {
1506                    [
1507                        base_color[0] * 0.5,
1508                        base_color[1] * 0.5,
1509                        base_color[2] * 0.5,
1510                        base_color[3] * 0.3,
1511                    ]
1512                };
1513                let border_color = if active {
1514                    [base_color[0], base_color[1], base_color[2], 0.9]
1515                } else if hovered {
1516                    [base_color[0], base_color[1], base_color[2], 0.8]
1517                } else {
1518                    [
1519                        base_color[0] * 0.9,
1520                        base_color[1] * 0.9,
1521                        base_color[2] * 0.9,
1522                        0.6,
1523                    ]
1524                };
1525
1526                let overlay = crate::interaction::clip_plane::ClipPlaneOverlay {
1527                    center,
1528                    normal: n,
1529                    extent: obj.extent,
1530                    fill_color,
1531                    border_color,
1532                    hovered,
1533                    active,
1534                };
1535                clip_plane_fill_buffers.push(
1536                    self.resources
1537                        .create_clip_plane_fill_overlay(device, &overlay),
1538                );
1539                clip_plane_line_buffers.push(
1540                    self.resources
1541                        .create_clip_plane_line_overlay(device, &overlay),
1542                );
1543            } else {
1544                // Box/Sphere: generate wireframe polyline.
1545                // ensure_polyline_pipeline must be called before upload_polyline; it is a
1546                // no-op if already initialised, so calling it here is always safe.
1547                self.resources.ensure_polyline_pipeline(device);
1548                match obj.shape {
1549                    ClipShape::Box {
1550                        center,
1551                        half_extents,
1552                        orientation,
1553                    } => {
1554                        let polyline =
1555                            clip_box_outline(center, half_extents, orientation, base_color);
1556                        let vp_size = frame.camera.viewport_size;
1557                        let gpu = self
1558                            .resources
1559                            .upload_polyline(device, queue, &polyline, vp_size);
1560                        self.polyline_gpu_data.push(gpu);
1561                    }
1562                    ClipShape::Sphere { center, radius } => {
1563                        let polyline = clip_sphere_outline(center, radius, base_color);
1564                        let vp_size = frame.camera.viewport_size;
1565                        let gpu = self
1566                            .resources
1567                            .upload_polyline(device, queue, &polyline, vp_size);
1568                        self.polyline_gpu_data.push(gpu);
1569                    }
1570                    _ => {}
1571                }
1572            }
1573        }
1574
1575        // Cap geometry for section-view cross-section fill.
1576        let mut cap_buffers = Vec::new();
1577        if viewport_fx.cap_fill_enabled {
1578            for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1579                if let ClipShape::Plane {
1580                    normal,
1581                    distance,
1582                    cap_color,
1583                } = obj.shape
1584                {
1585                    let plane_n = glam::Vec3::from(normal);
1586                    for item in scene_items.iter().filter(|i| i.visible) {
1587                        let Some(mesh) = self
1588                            .resources
1589                            .mesh_store
1590                            .get(item.mesh_id)
1591                        else {
1592                            continue;
1593                        };
1594                        let model = glam::Mat4::from_cols_array_2d(&item.model);
1595                        let world_aabb = mesh.aabb.transformed(&model);
1596                        if !world_aabb.intersects_plane(plane_n, distance) {
1597                            continue;
1598                        }
1599                        let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1600                        else {
1601                            continue;
1602                        };
1603                        if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
1604                            pos, idx, &model, plane_n, distance,
1605                        ) {
1606                            let bc = item.material.base_color;
1607                            let color = cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
1608                            let buf = self.resources.upload_cap_geometry(device, &cap, color);
1609                            cap_buffers.push(buf);
1610                        }
1611                    }
1612                }
1613            }
1614        }
1615
1616        // Axes indicator geometry (built here, written to slot buffer below).
1617        let axes_verts = if frame.viewport.show_axes_indicator
1618            && frame.camera.viewport_size[0] > 0.0
1619            && frame.camera.viewport_size[1] > 0.0
1620        {
1621            let verts = crate::widgets::axes_indicator::build_axes_geometry(
1622                frame.camera.viewport_size[0],
1623                frame.camera.viewport_size[1],
1624                frame.camera.render_camera.orientation,
1625            );
1626            if verts.is_empty() { None } else { Some(verts) }
1627        } else {
1628            None
1629        };
1630
1631        // Gizmo mesh + uniform (built here, written to slot buffers below).
1632        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
1633            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
1634                frame.interaction.gizmo_mode,
1635                frame.interaction.gizmo_hovered,
1636                frame.interaction.gizmo_space_orientation,
1637            );
1638            (verts, indices, model)
1639        });
1640
1641        // ------------------------------------------------------------------
1642        // Assign all interaction state to the per-viewport slot.
1643        // ------------------------------------------------------------------
1644        {
1645            let slot = &mut self.viewport_slots[vp_idx];
1646            slot.outline_object_buffers = outline_object_buffers;
1647            slot.xray_object_buffers = xray_object_buffers;
1648            slot.constraint_line_buffers = constraint_line_buffers;
1649            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
1650            slot.clip_plane_line_buffers = clip_plane_line_buffers;
1651            slot.cap_buffers = cap_buffers;
1652
1653            // Axes: resize buffer if needed, then upload.
1654            if let Some(verts) = axes_verts {
1655                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
1656                if byte_size > slot.axes_vertex_buffer.size() {
1657                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1658                        label: Some("vp_axes_vertex_buf"),
1659                        size: byte_size,
1660                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1661                        mapped_at_creation: false,
1662                    });
1663                }
1664                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
1665                slot.axes_vertex_count = verts.len() as u32;
1666            } else {
1667                slot.axes_vertex_count = 0;
1668            }
1669
1670            // Gizmo: resize buffers if needed, then upload mesh + uniform.
1671            if let Some((verts, indices, model)) = gizmo_update {
1672                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
1673                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
1674                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
1675                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1676                        label: Some("vp_gizmo_vertex_buf"),
1677                        size: vert_bytes.len() as u64,
1678                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1679                        mapped_at_creation: false,
1680                    });
1681                }
1682                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
1683                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1684                        label: Some("vp_gizmo_index_buf"),
1685                        size: idx_bytes.len() as u64,
1686                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1687                        mapped_at_creation: false,
1688                    });
1689                }
1690                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
1691                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
1692                slot.gizmo_index_count = indices.len() as u32;
1693                let uniform = crate::interaction::gizmo::GizmoUniform {
1694                    model: model.to_cols_array_2d(),
1695                };
1696                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
1697            }
1698        }
1699
1700        // ------------------------------------------------------------------
1701        // Outline offscreen pass : screen-space edge detection.
1702        //
1703        // 1. Render selected objects to an R8 mask texture (white on black).
1704        // 2. Run a fullscreen edge-detection pass reading the mask and writing
1705        //    an anti-aliased outline ring to the outline color texture.
1706        //
1707        // The outline color texture is later composited onto the main target
1708        // by the composite pass in paint()/render().
1709        // ------------------------------------------------------------------
1710        if frame.interaction.outline_selected
1711            && !self.viewport_slots[vp_idx]
1712                .outline_object_buffers
1713                .is_empty()
1714        {
1715            let w = frame.camera.viewport_size[0] as u32;
1716            let h = frame.camera.viewport_size[1] as u32;
1717
1718            // Ensure per-viewport HDR state exists (provides outline textures).
1719            self.ensure_viewport_hdr(
1720                device,
1721                queue,
1722                vp_idx,
1723                w.max(1),
1724                h.max(1),
1725                frame.effects.post_process.ssaa_factor.max(1),
1726            );
1727
1728            // Write edge-detection uniform (color, radius, viewport size).
1729            {
1730                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1731                let edge_uniform = OutlineEdgeUniform {
1732                    color: frame.interaction.outline_color,
1733                    radius: frame.interaction.outline_width_px,
1734                    viewport_w: w as f32,
1735                    viewport_h: h as f32,
1736                    _pad: 0.0,
1737                };
1738                queue.write_buffer(
1739                    &slot_hdr.outline_edge_uniform_buf,
1740                    0,
1741                    bytemuck::cast_slice(&[edge_uniform]),
1742                );
1743            }
1744
1745            // Extract raw pointers for slot fields needed inside the render
1746            // passes alongside &self.resources borrows.
1747            let slot_ref = &self.viewport_slots[vp_idx];
1748            let outlines_ptr =
1749                &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
1750            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
1751            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
1752            let mask_view_ptr = &slot_hdr.outline_mask_view as *const wgpu::TextureView;
1753            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
1754            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
1755            let edge_bg_ptr = &slot_hdr.outline_edge_bind_group as *const wgpu::BindGroup;
1756            // SAFETY: slot fields remain valid for the duration of this function;
1757            // no other code modifies these fields here.
1758            let (outlines, camera_bg, mask_view, color_view, depth_view, edge_bg) = unsafe {
1759                (
1760                    &*outlines_ptr,
1761                    &*camera_bg_ptr,
1762                    &*mask_view_ptr,
1763                    &*color_view_ptr,
1764                    &*depth_view_ptr,
1765                    &*edge_bg_ptr,
1766                )
1767            };
1768
1769            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1770                label: Some("outline_offscreen_encoder"),
1771            });
1772
1773            // Pass 1: render selected objects to R8 mask texture.
1774            {
1775                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1776                    label: Some("outline_mask_pass"),
1777                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1778                        view: mask_view,
1779                        resolve_target: None,
1780                        ops: wgpu::Operations {
1781                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1782                            store: wgpu::StoreOp::Store,
1783                        },
1784                        depth_slice: None,
1785                    })],
1786                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1787                        view: depth_view,
1788                        depth_ops: Some(wgpu::Operations {
1789                            load: wgpu::LoadOp::Clear(1.0),
1790                            store: wgpu::StoreOp::Discard,
1791                        }),
1792                        stencil_ops: None,
1793                    }),
1794                    timestamp_writes: None,
1795                    occlusion_query_set: None,
1796                });
1797
1798                pass.set_bind_group(0, camera_bg, &[]);
1799                for outlined in outlines {
1800                    let Some(mesh) = self
1801                        .resources
1802                        .mesh_store
1803                        .get(outlined.mesh_id)
1804                    else {
1805                        continue;
1806                    };
1807                    let pipeline = if outlined.two_sided {
1808                        &self.resources.outline_mask_two_sided_pipeline
1809                    } else {
1810                        &self.resources.outline_mask_pipeline
1811                    };
1812                    pass.set_pipeline(pipeline);
1813                    pass.set_bind_group(1, &outlined.mask_bind_group, &[]);
1814                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1815                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1816                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1817                }
1818            }
1819
1820            // Pass 2: fullscreen edge detection (reads mask, writes color).
1821            {
1822                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1823                    label: Some("outline_edge_pass"),
1824                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1825                        view: color_view,
1826                        resolve_target: None,
1827                        ops: wgpu::Operations {
1828                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1829                            store: wgpu::StoreOp::Store,
1830                        },
1831                        depth_slice: None,
1832                    })],
1833                    depth_stencil_attachment: None,
1834                    timestamp_writes: None,
1835                    occlusion_query_set: None,
1836                });
1837                pass.set_pipeline(&self.resources.outline_edge_pipeline);
1838                pass.set_bind_group(0, edge_bg, &[]);
1839                pass.draw(0..3, 0..1);
1840            }
1841
1842            queue.submit(std::iter::once(encoder.finish()));
1843        }
1844
1845        // ------------------------------------------------------------------
1846        // Sub-object highlight prepare: build GPU geometry from sub-selection
1847        // snapshot when the version has changed since the last frame.
1848        // ------------------------------------------------------------------
1849        {
1850            let w = frame.camera.viewport_size[0];
1851            let h = frame.camera.viewport_size[1];
1852            if let Some(sel_ref) = &frame.interaction.sub_selection {
1853                let needs_rebuild = {
1854                    let slot = &self.viewport_slots[vp_idx];
1855                    slot.sub_highlight_generation != sel_ref.version
1856                        || slot.sub_highlight.is_none()
1857                };
1858                if needs_rebuild {
1859                    self.resources.ensure_sub_highlight_pipelines(device);
1860                    let data = self.resources.build_sub_highlight(
1861                        device,
1862                        queue,
1863                        sel_ref,
1864                        frame.interaction.sub_highlight_face_fill_color,
1865                        frame.interaction.sub_highlight_edge_color,
1866                        frame.interaction.sub_highlight_edge_width_px,
1867                        frame.interaction.sub_highlight_vertex_size_px,
1868                        w,
1869                        h,
1870                    );
1871                    let slot = &mut self.viewport_slots[vp_idx];
1872                    slot.sub_highlight = Some(data);
1873                    slot.sub_highlight_generation = sel_ref.version;
1874                }
1875            } else {
1876                let slot = &mut self.viewport_slots[vp_idx];
1877                slot.sub_highlight = None;
1878                slot.sub_highlight_generation = u64::MAX;
1879            }
1880        }
1881
1882        // ---------------------------------------------------------------
1883        // Overlay labels
1884        // ---------------------------------------------------------------
1885        self.label_gpu_data = None;
1886        if !frame.overlays.labels.is_empty() {
1887            self.resources.ensure_overlay_text_pipeline(device);
1888            let vp_w = frame.camera.viewport_size[0];
1889            let vp_h = frame.camera.viewport_size[1];
1890            if vp_w > 0.0 && vp_h > 0.0 {
1891                let view = &frame.camera.render_camera.view;
1892                let proj = &frame.camera.render_camera.projection;
1893
1894                // Sort by z_order for correct draw ordering.
1895                let mut sorted_labels: Vec<&crate::renderer::types::LabelItem> =
1896                    frame.overlays.labels.iter().collect();
1897                sorted_labels.sort_by_key(|l| l.z_order);
1898
1899                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
1900
1901                for label in &sorted_labels {
1902                    if label.text.is_empty() || label.opacity <= 0.0 {
1903                        continue;
1904                    }
1905
1906                    // Resolve screen position from anchor.
1907                    let screen_pos = if let Some(sa) = label.screen_anchor {
1908                        Some(sa)
1909                    } else if let Some(wa) = label.world_anchor {
1910                        project_to_screen(wa, view, proj, vp_w, vp_h)
1911                    } else {
1912                        continue;
1913                    };
1914                    let Some(anchor_px) = screen_pos else {
1915                        continue;
1916                    };
1917
1918                    let opacity = label.opacity.clamp(0.0, 1.0);
1919
1920                    // Layout text (with optional word wrapping).
1921                    let layout = if let Some(max_w) = label.max_width {
1922                        self.resources.glyph_atlas.layout_text_wrapped(
1923                            &label.text,
1924                            label.font_size,
1925                            label.font,
1926                            max_w,
1927                            device,
1928                        )
1929                    } else {
1930                        self.resources.glyph_atlas.layout_text(
1931                            &label.text,
1932                            label.font_size,
1933                            label.font,
1934                            device,
1935                        )
1936                    };
1937
1938                    // Compute ascent so glyphs are positioned below the anchor.
1939                    let font_index = label.font.map_or(0, |h| h.0);
1940                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, label.font_size);
1941
1942                    // Horizontal alignment.
1943                    let align_offset = match label.anchor_align {
1944                        crate::renderer::types::LabelAnchor::Leading => 6.0,
1945                        crate::renderer::types::LabelAnchor::Center => -layout.total_width * 0.5,
1946                        crate::renderer::types::LabelAnchor::Trailing => -layout.total_width - 6.0,
1947                    };
1948
1949                    // Text origin with alignment + user offset.
1950                    let text_x = anchor_px[0] + align_offset + label.offset[0];
1951                    let text_y = anchor_px[1] - layout.height * 0.5 + label.offset[1];
1952
1953                    // Background box (drawn first, behind text).
1954                    if label.background {
1955                        let pad = label.padding;
1956                        let bx0 = text_x - pad;
1957                        let by0 = text_y - pad;
1958                        let bx1 = text_x + layout.total_width + pad;
1959                        let by1 = text_y + layout.height + pad;
1960                        let bg_color = apply_opacity(label.background_color, opacity);
1961                        if label.border_radius > 0.0 {
1962                            emit_rounded_quad(
1963                                &mut verts,
1964                                bx0, by0, bx1, by1,
1965                                label.border_radius,
1966                                bg_color,
1967                                vp_w, vp_h,
1968                            );
1969                        } else {
1970                            emit_solid_quad(
1971                                &mut verts,
1972                                bx0, by0, bx1, by1,
1973                                bg_color,
1974                                vp_w, vp_h,
1975                            );
1976                        }
1977                    }
1978
1979                    // Leader line.
1980                    if label.leader_line {
1981                        if let Some(wa) = label.world_anchor {
1982                            let world_px = project_to_screen(wa, view, proj, vp_w, vp_h);
1983                            if let Some(wp) = world_px {
1984                                emit_line_quad(
1985                                    &mut verts,
1986                                    wp[0], wp[1],
1987                                    text_x, text_y + layout.height * 0.5,
1988                                    1.5,
1989                                    apply_opacity(label.leader_color, opacity),
1990                                    vp_w, vp_h,
1991                                );
1992                            }
1993                        }
1994                    }
1995
1996                    // Glyph quads.
1997                    let text_color = apply_opacity(label.color, opacity);
1998                    for gq in &layout.quads {
1999                        let gx = text_x + gq.pos[0];
2000                        let gy = text_y + ascent + gq.pos[1];
2001                        emit_textured_quad(
2002                            &mut verts,
2003                            gx, gy,
2004                            gx + gq.size[0], gy + gq.size[1],
2005                            gq.uv_min, gq.uv_max,
2006                            text_color,
2007                            vp_w, vp_h,
2008                        );
2009                    }
2010                }
2011
2012                // Upload atlas if new glyphs were rasterized.
2013                self.resources.glyph_atlas.upload_if_dirty(queue);
2014
2015                if !verts.is_empty() {
2016                    let vertex_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2017                        label: Some("overlay_label_vbuf"),
2018                        contents: bytemuck::cast_slice(&verts),
2019                        usage: wgpu::BufferUsages::VERTEX,
2020                    });
2021                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2022                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2023                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2024                        label: Some("overlay_label_bg"),
2025                        layout: bgl,
2026                        entries: &[
2027                            wgpu::BindGroupEntry {
2028                                binding: 0,
2029                                resource: wgpu::BindingResource::TextureView(
2030                                    &self.resources.glyph_atlas.view,
2031                                ),
2032                            },
2033                            wgpu::BindGroupEntry {
2034                                binding: 1,
2035                                resource: wgpu::BindingResource::Sampler(sampler),
2036                            },
2037                        ],
2038                    });
2039                    self.label_gpu_data = Some(crate::resources::LabelGpuData {
2040                        vertex_buf,
2041                        vertex_count: verts.len() as u32,
2042                        bind_group,
2043                    });
2044                }
2045            }
2046        }
2047
2048        // ---------------------------------------------------------------
2049        // Scalar bars
2050        // ---------------------------------------------------------------
2051        self.scalar_bar_gpu_data = None;
2052        if !frame.overlays.scalar_bars.is_empty() {
2053            self.resources.ensure_overlay_text_pipeline(device);
2054            let vp_w = frame.camera.viewport_size[0];
2055            let vp_h = frame.camera.viewport_size[1];
2056            if vp_w > 0.0 && vp_h > 0.0 {
2057                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2058
2059                for bar in &frame.overlays.scalar_bars {
2060                    // Clone the LUT immediately so the immutable borrow on self.resources
2061                    // is released before the mutable glyph_atlas borrow below.
2062                    let Some(lut) = self.resources.get_colormap_rgba(bar.colormap_id).map(|l| l.to_vec()) else {
2063                        continue;
2064                    };
2065
2066                    let is_vertical = matches!(
2067                        bar.orientation,
2068                        crate::renderer::types::ScalarBarOrientation::Vertical
2069                    );
2070                    let reversed = bar.ticks_reversed;
2071
2072                    // Effective font sizes.
2073                    let tick_fs = bar.font_size;
2074                    let title_fs = bar.title_font_size.unwrap_or(bar.font_size);
2075                    let font_index = bar.font.map_or(0, |h| h.0);
2076
2077                    // Actual pixel dimensions of the gradient strip.
2078                    let (strip_w, strip_h) = if is_vertical {
2079                        (bar.bar_width_px, bar.bar_length_px)
2080                    } else {
2081                        (bar.bar_length_px, bar.bar_width_px)
2082                    };
2083
2084                    // Pre-compute tick texts and their widths so the background box
2085                    // can be sized to cover the tick labels.
2086                    let tick_count = bar.tick_count.max(2);
2087                    let mut tick_data: Vec<(String, f32, f32)> = Vec::new(); // (text, total_w, height)
2088                    let mut max_tick_w = 0.0f32;
2089                    let mut tick_h = 0.0f32;
2090                    for i in 0..tick_count {
2091                        let t = i as f32 / (tick_count - 1) as f32;
2092                        let value = bar.scalar_min + t * (bar.scalar_max - bar.scalar_min);
2093                        let text = format!("{value:.2}");
2094                        let layout = self.resources.glyph_atlas.layout_text(
2095                            &text, tick_fs, bar.font, device,
2096                        );
2097                        max_tick_w = max_tick_w.max(layout.total_width);
2098                        tick_h = layout.height;
2099                        tick_data.push((text, layout.total_width, layout.height));
2100                    }
2101
2102                    // Vertical space reserved above the gradient strip.
2103                    // In vertical mode the top/bottom tick labels are centred on the strip
2104                    // endpoints, so they each overhang by tick_h/2. title_h must absorb the
2105                    // top overhang AND leave a gap so the title text does not touch the tick.
2106                    let half_tick = tick_h / 2.0;
2107                    let title_h = if bar.title.is_some() {
2108                        // title text height + small gap + top-tick overhang
2109                        title_fs + 4.0 + half_tick
2110                    } else {
2111                        // no title, but still need room for the top-tick overhang
2112                        half_tick
2113                    };
2114
2115                    // Pre-compute title width before bar_x/bar_y so the overhang can
2116                    // be used to push the strip inward and prevent clipping.
2117                    let title_w = if let Some(ref t) = bar.title {
2118                        self.resources.glyph_atlas.layout_text(t, title_fs, bar.font, device).total_width
2119                    } else {
2120                        0.0
2121                    };
2122
2123                    // How far title / tick labels spill beyond the strip on each side.
2124                    // Vertical: title centred on the narrow strip, ticks to the right.
2125                    //   left side: title overhang only.
2126                    //   right side: ticks dominate (strip_w + 4 + max_tick_w).
2127                    // Horizontal: both title and tick labels can overhang left/right equally.
2128                    let bg_pad = 4.0;
2129                    let (inset_left, inset_right) = if is_vertical {
2130                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
2131                        let right_extent = 4.0 + max_tick_w + bg_pad; // relative to strip right edge
2132                        (title_oh + bg_pad, right_extent)
2133                    } else {
2134                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
2135                        let tick_oh  = max_tick_w / 2.0;
2136                        let side = title_oh.max(tick_oh) + bg_pad;
2137                        (side, side)
2138                    };
2139
2140                    // How far content hangs below the strip bottom (used to keep the
2141                    // background box flush with margin_px on the bottom-anchored side).
2142                    // Vertical: bottom tick label is centred on the strip endpoint -> half_tick.
2143                    // Horizontal: tick labels sit fully below the strip -> 3 + tick_h.
2144                    let bottom_overhang = if is_vertical { half_tick } else { 3.0 + tick_h };
2145
2146                    // Top-left of the gradient strip.
2147                    // bg_pad is added/subtracted here so that the background box edge lands
2148                    // exactly at margin_px from the viewport edge on the anchored side.
2149                    //   Top anchor:    bg_y0 = bar_y - title_h - bg_pad  =>  set bar_y = margin_px + title_h + bg_pad
2150                    //   Bottom anchor: bg_y1 = bar_y + strip_h + bottom_overhang + bg_pad  =>  bar_y = vp_h - margin_px - strip_h - bottom_overhang - bg_pad
2151                    let (bar_x, bar_y) = match bar.anchor {
2152                        crate::renderer::types::ScalarBarAnchor::TopLeft => (
2153                            bar.margin_px + inset_left,
2154                            bar.margin_px + title_h + bg_pad,
2155                        ),
2156                        crate::renderer::types::ScalarBarAnchor::TopRight => (
2157                            vp_w - bar.margin_px - strip_w - inset_right,
2158                            bar.margin_px + title_h + bg_pad,
2159                        ),
2160                        crate::renderer::types::ScalarBarAnchor::BottomLeft => (
2161                            bar.margin_px + inset_left,
2162                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
2163                        ),
2164                        crate::renderer::types::ScalarBarAnchor::BottomRight => (
2165                            vp_w - bar.margin_px - strip_w - inset_right,
2166                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
2167                        ),
2168                    };
2169
2170                    // Background box: now that bar_x/bar_y are inset, the box stays on screen.
2171                    let (bg_x0, bg_y0, bg_x1, bg_y1) = if is_vertical {
2172                        let title_right = bar_x + (strip_w + title_w) / 2.0;
2173                        let ticks_right = bar_x + strip_w + 4.0 + max_tick_w;
2174                        (
2175                            bar_x - bg_pad - ((title_w - strip_w) / 2.0).max(0.0),
2176                            bar_y - title_h - bg_pad,
2177                            ticks_right.max(title_right) + bg_pad,
2178                            bar_y + strip_h + half_tick + bg_pad,
2179                        )
2180                    } else {
2181                        let title_overhang = ((title_w - strip_w) / 2.0).max(0.0);
2182                        let tick_overhang  = max_tick_w / 2.0;
2183                        let side_pad = title_overhang.max(tick_overhang);
2184                        let bottom = bar_y + strip_h + 3.0 + tick_h + bg_pad;
2185                        (
2186                            bar_x - bg_pad - side_pad,
2187                            bar_y - title_h - bg_pad,
2188                            bar_x + strip_w + bg_pad + side_pad,
2189                            bottom,
2190                        )
2191                    };
2192                    emit_rounded_quad(
2193                        &mut verts,
2194                        bg_x0, bg_y0, bg_x1, bg_y1,
2195                        3.0,
2196                        bar.background_color,
2197                        vp_w, vp_h,
2198                    );
2199
2200                    // Gradient strip: 64 solid quads sampled from the colormap LUT.
2201                    let steps: usize = 64;
2202                    for s in 0..steps {
2203                        let (qx0, qy0, qx1, qy1, t) = if is_vertical {
2204                            // Default: top = max (t=1). Reversed: top = min (t=0).
2205                            let t = if reversed {
2206                                s as f32 / (steps - 1) as f32
2207                            } else {
2208                                1.0 - s as f32 / (steps - 1) as f32
2209                            };
2210                            let step_h = strip_h / steps as f32;
2211                            let sy = bar_y + s as f32 * step_h;
2212                            (bar_x, sy, bar_x + strip_w, sy + step_h + 0.5, t)
2213                        } else {
2214                            // Default: left = min (t=0). Reversed: left = max (t=1).
2215                            let t = if reversed {
2216                                1.0 - s as f32 / (steps - 1) as f32
2217                            } else {
2218                                s as f32 / (steps - 1) as f32
2219                            };
2220                            let step_w = strip_w / steps as f32;
2221                            let sx = bar_x + s as f32 * step_w;
2222                            (sx, bar_y, sx + step_w + 0.5, bar_y + strip_h, t)
2223                        };
2224                        let lut_idx = (t * 255.0).clamp(0.0, 255.0) as usize;
2225                        let [r, g, b, a] = lut[lut_idx];
2226                        let color = [
2227                            r as f32 / 255.0,
2228                            g as f32 / 255.0,
2229                            b as f32 / 255.0,
2230                            a as f32 / 255.0,
2231                        ];
2232                        emit_solid_quad(&mut verts, qx0, qy0, qx1, qy1, color, vp_w, vp_h);
2233                    }
2234
2235                    // Tick labels.
2236                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, tick_fs);
2237                    for (i, (text, tw, th)) in tick_data.iter().enumerate() {
2238                        let t = i as f32 / (tick_count - 1) as f32;
2239                        let layout = self.resources.glyph_atlas.layout_text(
2240                            text, tick_fs, bar.font, device,
2241                        );
2242
2243                        let (lx, ly) = if is_vertical {
2244                            // Place text to the right of the strip, vertically centered
2245                            // on its tick position.
2246                            // Default: top=max -> progress = 1.0-t puts max at top.
2247                            // Reversed: top=min -> progress = t puts min at top.
2248                            let progress = if reversed { t } else { 1.0 - t };
2249                            let tick_y = bar_y + progress * strip_h;
2250                            (bar_x + strip_w + 4.0, tick_y - th * 0.5)
2251                        } else {
2252                            // Place text below the strip, horizontally centered on its tick.
2253                            // Default: left=min -> tick at t*strip_w.
2254                            // Reversed: left=max -> tick at (1-t)*strip_w.
2255                            let frac = if reversed { 1.0 - t } else { t };
2256                            let tick_x = bar_x + frac * strip_w;
2257                            (tick_x - tw * 0.5, bar_y + strip_h + 3.0)
2258                        };
2259                        let _ = (tw, th); // used above
2260
2261                        for gq in &layout.quads {
2262                            let gx = lx + gq.pos[0];
2263                            let gy = ly + ascent + gq.pos[1];
2264                            emit_textured_quad(
2265                                &mut verts,
2266                                gx, gy,
2267                                gx + gq.size[0], gy + gq.size[1],
2268                                gq.uv_min, gq.uv_max,
2269                                bar.label_color,
2270                                vp_w, vp_h,
2271                            );
2272                        }
2273                    }
2274
2275                    // Optional title above the gradient strip.
2276                    if let Some(ref title_text) = bar.title {
2277                        let layout = self.resources.glyph_atlas.layout_text(
2278                            title_text, title_fs, bar.font, device,
2279                        );
2280                        let title_ascent = self.resources.glyph_atlas.font_ascent(font_index, title_fs);
2281                        // Centre the title over the gradient strip.
2282                        let tx = bar_x + (strip_w - layout.total_width) * 0.5;
2283                        let ty = bar_y - title_h;
2284                        for gq in &layout.quads {
2285                            let gx = tx + gq.pos[0];
2286                            let gy = ty + title_ascent + gq.pos[1];
2287                            emit_textured_quad(
2288                                &mut verts,
2289                                gx, gy,
2290                                gx + gq.size[0], gy + gq.size[1],
2291                                gq.uv_min, gq.uv_max,
2292                                bar.label_color,
2293                                vp_w, vp_h,
2294                            );
2295                        }
2296                    }
2297                }
2298
2299                // Upload any newly rasterized glyphs (may overlap with label upload above).
2300                self.resources.glyph_atlas.upload_if_dirty(queue);
2301
2302                if !verts.is_empty() {
2303                    let vertex_buf =
2304                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2305                            label: Some("overlay_scalar_bar_vbuf"),
2306                            contents: bytemuck::cast_slice(&verts),
2307                            usage: wgpu::BufferUsages::VERTEX,
2308                        });
2309                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2310                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2311                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2312                        label: Some("overlay_scalar_bar_bg"),
2313                        layout: bgl,
2314                        entries: &[
2315                            wgpu::BindGroupEntry {
2316                                binding: 0,
2317                                resource: wgpu::BindingResource::TextureView(
2318                                    &self.resources.glyph_atlas.view,
2319                                ),
2320                            },
2321                            wgpu::BindGroupEntry {
2322                                binding: 1,
2323                                resource: wgpu::BindingResource::Sampler(sampler),
2324                            },
2325                        ],
2326                    });
2327                    self.scalar_bar_gpu_data = Some(crate::resources::LabelGpuData {
2328                        vertex_buf,
2329                        vertex_count: verts.len() as u32,
2330                        bind_group,
2331                    });
2332                }
2333            }
2334        }
2335
2336        // ---------------------------------------------------------------
2337        // Rulers
2338        // ---------------------------------------------------------------
2339        self.ruler_gpu_data = None;
2340        if !frame.overlays.rulers.is_empty() {
2341            self.resources.ensure_overlay_text_pipeline(device);
2342            let vp_w = frame.camera.viewport_size[0];
2343            let vp_h = frame.camera.viewport_size[1];
2344            if vp_w > 0.0 && vp_h > 0.0 {
2345                let view = &frame.camera.render_camera.view;
2346                let proj = &frame.camera.render_camera.projection;
2347
2348                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2349
2350                for ruler in &frame.overlays.rulers {
2351                    // Project both endpoints to NDC (returns None only if behind camera).
2352                    let start_ndc = project_to_ndc(ruler.start, view, proj);
2353                    let end_ndc   = project_to_ndc(ruler.end,   view, proj);
2354
2355                    // Cull entirely when either endpoint is behind the camera.
2356                    let (Some(sndc), Some(endc)) = (start_ndc, end_ndc) else { continue };
2357
2358                    // Clip the segment to the viewport NDC box [-1,1]^2.
2359                    // This keeps the line visible when only one end is off-screen sideways.
2360                    let Some((csndc, cendc)) = clip_line_ndc(sndc, endc) else { continue };
2361
2362                    let [sx, sy] = ndc_to_screen_px(csndc, vp_w, vp_h);
2363                    let [ex, ey] = ndc_to_screen_px(cendc, vp_w, vp_h);
2364
2365                    // Track which original endpoints are within the viewport (for end caps).
2366                    let start_on_screen = ndc_in_viewport(sndc);
2367                    let end_on_screen   = ndc_in_viewport(endc);
2368
2369                    // Main ruler line.
2370                    emit_line_quad(
2371                        &mut verts,
2372                        sx, sy, ex, ey,
2373                        ruler.line_width_px,
2374                        ruler.color,
2375                        vp_w, vp_h,
2376                    );
2377
2378                    // End caps only at endpoints that are actually on screen.
2379                    if ruler.end_caps {
2380                        let dx = ex - sx;
2381                        let dy = ey - sy;
2382                        let len = (dx * dx + dy * dy).sqrt().max(0.001);
2383                        let cap_half = 5.0;
2384                        let px = -dy / len * cap_half;
2385                        let py =  dx / len * cap_half;
2386
2387                        if start_on_screen {
2388                            emit_line_quad(
2389                                &mut verts,
2390                                sx - px, sy - py,
2391                                sx + px, sy + py,
2392                                ruler.line_width_px,
2393                                ruler.color,
2394                                vp_w, vp_h,
2395                            );
2396                        }
2397                        if end_on_screen {
2398                            emit_line_quad(
2399                                &mut verts,
2400                                ex - px, ey - py,
2401                                ex + px, ey + py,
2402                                ruler.line_width_px,
2403                                ruler.color,
2404                                vp_w, vp_h,
2405                            );
2406                        }
2407                    }
2408
2409                    // Distance label: always shows true 3D distance.
2410                    // Place it at the midpoint of the visible (clipped) segment.
2411                    let start_world = glam::Vec3::from(ruler.start);
2412                    let end_world = glam::Vec3::from(ruler.end);
2413                    let distance = (end_world - start_world).length();
2414                    let text = format_ruler_distance(distance, ruler.label_format.as_deref());
2415
2416                    let mid_x = (sx + ex) * 0.5;
2417                    let mid_y = (sy + ey) * 0.5;
2418
2419                    let layout = self.resources.glyph_atlas.layout_text(
2420                        &text,
2421                        ruler.font_size,
2422                        ruler.font,
2423                        device,
2424                    );
2425                    let font_index = ruler.font.map_or(0, |h| h.0);
2426                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, ruler.font_size);
2427
2428                    // Center the label above the midpoint with a small gap.
2429                    let lx = mid_x - layout.total_width * 0.5;
2430                    let ly = mid_y - layout.height - 6.0;
2431
2432                    // Semi-transparent background box.
2433                    let pad = 3.0;
2434                    emit_solid_quad(
2435                        &mut verts,
2436                        lx - pad, ly - pad,
2437                        lx + layout.total_width + pad, ly + layout.height + pad,
2438                        [0.0, 0.0, 0.0, 0.55],
2439                        vp_w, vp_h,
2440                    );
2441
2442                    // Glyph quads.
2443                    for gq in &layout.quads {
2444                        let gx = lx + gq.pos[0];
2445                        let gy = ly + ascent + gq.pos[1];
2446                        emit_textured_quad(
2447                            &mut verts,
2448                            gx, gy,
2449                            gx + gq.size[0], gy + gq.size[1],
2450                            gq.uv_min, gq.uv_max,
2451                            ruler.label_color,
2452                            vp_w, vp_h,
2453                        );
2454                    }
2455                }
2456
2457                // Upload any newly rasterized glyphs.
2458                self.resources.glyph_atlas.upload_if_dirty(queue);
2459
2460                if !verts.is_empty() {
2461                    let vertex_buf =
2462                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2463                            label: Some("overlay_ruler_vbuf"),
2464                            contents: bytemuck::cast_slice(&verts),
2465                            usage: wgpu::BufferUsages::VERTEX,
2466                        });
2467                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2468                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2469                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2470                        label: Some("overlay_ruler_bg"),
2471                        layout: bgl,
2472                        entries: &[
2473                            wgpu::BindGroupEntry {
2474                                binding: 0,
2475                                resource: wgpu::BindingResource::TextureView(
2476                                    &self.resources.glyph_atlas.view,
2477                                ),
2478                            },
2479                            wgpu::BindGroupEntry {
2480                                binding: 1,
2481                                resource: wgpu::BindingResource::Sampler(sampler),
2482                            },
2483                        ],
2484                    });
2485                    self.ruler_gpu_data = Some(crate::resources::LabelGpuData {
2486                        vertex_buf,
2487                        vertex_count: verts.len() as u32,
2488                        bind_group,
2489                    });
2490                }
2491            }
2492        }
2493    }
2494
2495    /// Upload per-frame data to GPU buffers and render the shadow pass.
2496    /// Call before `paint()`.
2497    ///
2498    /// Returns [`crate::FrameStats`] with per-frame timing and upload metrics.
2499    pub fn prepare(
2500        &mut self,
2501        device: &wgpu::Device,
2502        queue: &wgpu::Queue,
2503        frame: &FrameData,
2504    ) -> crate::renderer::stats::FrameStats {
2505        let prepare_start = std::time::Instant::now();
2506
2507        // Phase 4 : read back GPU timestamps from the previous frame, if available.
2508        // By the time prepare() is called, the previous frame's queue.submit() has
2509        // already happened, so it is safe to initiate the map here.
2510        if self.ts_needs_readback {
2511            if let Some(ref stg_buf) = self.ts_staging_buf {
2512                let (tx, rx) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
2513                stg_buf.slice(..).map_async(wgpu::MapMode::Read, move |r| {
2514                    let _ = tx.send(r);
2515                });
2516                // Non-blocking poll: flush any completed callbacks. GPU work from the
2517                // previous frame is almost certainly done by the time CPU reaches here.
2518                device
2519                    .poll(wgpu::PollType::Wait {
2520                        submission_index: None,
2521                        timeout: Some(std::time::Duration::from_millis(100)),
2522                    })
2523                    .ok();
2524                if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
2525                    let data = stg_buf.slice(..).get_mapped_range();
2526                    let t0 = u64::from_le_bytes(data[0..8].try_into().unwrap());
2527                    let t1 = u64::from_le_bytes(data[8..16].try_into().unwrap());
2528                    drop(data);
2529                    // ts_period is nanoseconds/tick; convert delta to milliseconds.
2530                    let gpu_ms = t1.saturating_sub(t0) as f32 * self.ts_period / 1_000_000.0;
2531                    self.last_stats.gpu_frame_ms = Some(gpu_ms);
2532                }
2533                stg_buf.unmap();
2534            }
2535            self.ts_needs_readback = false;
2536        }
2537
2538        // Wall-clock duration since the previous prepare() call approximates the frame interval.
2539        let total_frame_ms = self
2540            .last_prepare_instant
2541            .map(|t| t.elapsed().as_secs_f32() * 1000.0)
2542            .unwrap_or(0.0);
2543
2544        // Snapshot geometry upload bytes accumulated since the last frame, then reset.
2545        let upload_bytes = self.resources.frame_upload_bytes;
2546        self.resources.frame_upload_bytes = 0;
2547
2548        let (scene_fx, viewport_fx) = frame.effects.split();
2549        self.prepare_scene_internal(device, queue, frame, &scene_fx);
2550        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
2551
2552        let cpu_prepare_ms = prepare_start.elapsed().as_secs_f32() * 1000.0;
2553
2554        let policy = self.performance_policy;
2555        let budget_ms = policy.target_fps.map(|fps| 1000.0 / fps);
2556        let missed_budget = budget_ms
2557            .map(|b| total_frame_ms > b)
2558            .unwrap_or(false);
2559
2560        // Adaptation controller: adjust render scale within policy bounds when enabled.
2561        // Uses total_frame_ms from the *previous* frame so the controller reacts one
2562        // frame after the overrun, which is the earliest it can have the measurement.
2563        if policy.allow_dynamic_resolution {
2564            if let Some(budget) = budget_ms {
2565                if total_frame_ms > budget {
2566                    // Over budget: step down quickly.
2567                    self.current_render_scale =
2568                        (self.current_render_scale - 0.1).max(policy.min_render_scale);
2569                } else if total_frame_ms < budget * 0.8 {
2570                    // Comfortably under budget: recover slowly to avoid oscillation.
2571                    self.current_render_scale =
2572                        (self.current_render_scale + 0.05).min(policy.max_render_scale);
2573                }
2574            }
2575        }
2576
2577        self.last_prepare_instant = Some(prepare_start);
2578        self.frame_counter = self.frame_counter.wrapping_add(1);
2579
2580        let stats = crate::renderer::stats::FrameStats {
2581            cpu_prepare_ms,
2582            // gpu_frame_ms is updated by the timestamp readback above when available;
2583            // propagate the most recent value from last_stats.
2584            gpu_frame_ms: self.last_stats.gpu_frame_ms,
2585            total_frame_ms,
2586            render_scale: self.current_render_scale,
2587            missed_budget,
2588            upload_bytes,
2589            ..self.last_stats
2590        };
2591        self.last_stats = stats;
2592        stats
2593    }
2594}
2595
2596// ---------------------------------------------------------------------------
2597// Clip boundary wireframe helpers (used by prepare_viewport_internal)
2598// ---------------------------------------------------------------------------
2599
2600/// Wireframe outline for a clip box (12 edges as 2-point polyline strips).
2601fn clip_box_outline(
2602    center: [f32; 3],
2603    half: [f32; 3],
2604    orientation: [[f32; 3]; 3],
2605    color: [f32; 4],
2606) -> PolylineItem {
2607    let ax = glam::Vec3::from(orientation[0]) * half[0];
2608    let ay = glam::Vec3::from(orientation[1]) * half[1];
2609    let az = glam::Vec3::from(orientation[2]) * half[2];
2610    let c = glam::Vec3::from(center);
2611
2612    let corners = [
2613        c - ax - ay - az,
2614        c + ax - ay - az,
2615        c + ax + ay - az,
2616        c - ax + ay - az,
2617        c - ax - ay + az,
2618        c + ax - ay + az,
2619        c + ax + ay + az,
2620        c - ax + ay + az,
2621    ];
2622    let edges: [(usize, usize); 12] = [
2623        (0, 1),
2624        (1, 2),
2625        (2, 3),
2626        (3, 0), // bottom face
2627        (4, 5),
2628        (5, 6),
2629        (6, 7),
2630        (7, 4), // top face
2631        (0, 4),
2632        (1, 5),
2633        (2, 6),
2634        (3, 7), // verticals
2635    ];
2636
2637    let mut positions = Vec::with_capacity(24);
2638    let mut strip_lengths = Vec::with_capacity(12);
2639    for (a, b) in edges {
2640        positions.push(corners[a].to_array());
2641        positions.push(corners[b].to_array());
2642        strip_lengths.push(2u32);
2643    }
2644
2645    let mut item = PolylineItem::default();
2646    item.positions = positions;
2647    item.strip_lengths = strip_lengths;
2648    item.default_color = color;
2649    item.line_width = 2.0;
2650    item
2651}
2652
2653/// Wireframe outline for a clip sphere (three great circles).
2654fn clip_sphere_outline(center: [f32; 3], radius: f32, color: [f32; 4]) -> PolylineItem {
2655    let c = glam::Vec3::from(center);
2656    let segs = 64usize;
2657    let mut positions = Vec::with_capacity((segs + 1) * 3);
2658    let mut strip_lengths = Vec::with_capacity(3);
2659
2660    for axis in 0..3usize {
2661        let start = positions.len();
2662        for i in 0..=segs {
2663            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
2664            let (s, cs) = t.sin_cos();
2665            let p = c + match axis {
2666                0 => glam::Vec3::new(cs * radius, s * radius, 0.0),
2667                1 => glam::Vec3::new(cs * radius, 0.0, s * radius),
2668                _ => glam::Vec3::new(0.0, cs * radius, s * radius),
2669            };
2670            positions.push(p.to_array());
2671        }
2672        strip_lengths.push((positions.len() - start) as u32);
2673    }
2674
2675    let mut item = PolylineItem::default();
2676    item.positions = positions;
2677    item.strip_lengths = strip_lengths;
2678    item.default_color = color;
2679    item.line_width = 2.0;
2680    item
2681}
2682
2683// ---------------------------------------------------------------------------
2684// Overlay label helpers
2685// ---------------------------------------------------------------------------
2686
2687/// Project a world-space position to NDC.
2688/// Returns `None` only if the point is behind the camera (`clip.w <= 0`).
2689/// Does NOT reject points outside the [-1,1] viewport box.
2690fn project_to_ndc(
2691    pos: [f32; 3],
2692    view: &glam::Mat4,
2693    proj: &glam::Mat4,
2694) -> Option<[f32; 2]> {
2695    let clip = *proj * *view * glam::Vec3::from(pos).extend(1.0);
2696    if clip.w <= 0.0 { return None; }
2697    Some([clip.x / clip.w, clip.y / clip.w])
2698}
2699
2700/// Convert NDC coordinates to screen pixels (top-left origin).
2701fn ndc_to_screen_px(ndc: [f32; 2], vp_w: f32, vp_h: f32) -> [f32; 2] {
2702    [
2703        (ndc[0] * 0.5 + 0.5) * vp_w,
2704        (1.0 - (ndc[1] * 0.5 + 0.5)) * vp_h,
2705    ]
2706}
2707
2708/// Returns true when the NDC point lies within the viewport square.
2709fn ndc_in_viewport(ndc: [f32; 2]) -> bool {
2710    ndc[0] >= -1.0 && ndc[0] <= 1.0 && ndc[1] >= -1.0 && ndc[1] <= 1.0
2711}
2712
2713/// Clip a line segment [a, b] in NDC to the [-1,1]^2 viewport box
2714/// using the Liang-Barsky algorithm.
2715/// Returns the clipped endpoints, or `None` if the segment is entirely outside.
2716fn clip_line_ndc(a: [f32; 2], b: [f32; 2]) -> Option<([f32; 2], [f32; 2])> {
2717    let dx = b[0] - a[0];
2718    let dy = b[1] - a[1];
2719    let mut t0 = 0.0f32;
2720    let mut t1 = 1.0f32;
2721
2722    // (p, q) pairs for left, right, bottom, top boundaries.
2723    for (p, q) in [
2724        (-dx, a[0] + 1.0),
2725        ( dx, 1.0 - a[0]),
2726        (-dy, a[1] + 1.0),
2727        ( dy, 1.0 - a[1]),
2728    ] {
2729        if p == 0.0 {
2730            if q < 0.0 { return None; }
2731        } else {
2732            let r = q / p;
2733            if p < 0.0 { t0 = t0.max(r); } else { t1 = t1.min(r); }
2734        }
2735    }
2736
2737    if t0 > t1 { return None; }
2738    Some((
2739        [a[0] + t0 * dx, a[1] + t0 * dy],
2740        [a[0] + t1 * dx, a[1] + t1 * dy],
2741    ))
2742}
2743
2744/// Project a world-space position to screen pixels (top-left origin).
2745/// Returns `None` if behind the camera or outside the frustum.
2746fn project_to_screen(
2747    pos: [f32; 3],
2748    view: &glam::Mat4,
2749    proj: &glam::Mat4,
2750    vp_w: f32,
2751    vp_h: f32,
2752) -> Option<[f32; 2]> {
2753    let p = glam::Vec3::from(pos);
2754    let clip = *proj * *view * p.extend(1.0);
2755    if clip.w <= 0.0 {
2756        return None;
2757    }
2758    let ndc_x = clip.x / clip.w;
2759    let ndc_y = clip.y / clip.w;
2760    if ndc_x < -1.0 || ndc_x > 1.0 || ndc_y < -1.0 || ndc_y > 1.0 {
2761        return None;
2762    }
2763    let x = (ndc_x * 0.5 + 0.5) * vp_w;
2764    let y = (1.0 - (ndc_y * 0.5 + 0.5)) * vp_h;
2765    Some([x, y])
2766}
2767
2768/// Convert screen pixel coordinates to NDC.
2769#[inline]
2770fn px_to_ndc(px_x: f32, px_y: f32, vp_w: f32, vp_h: f32) -> [f32; 2] {
2771    [
2772        px_x / vp_w * 2.0 - 1.0,
2773        1.0 - px_y / vp_h * 2.0,
2774    ]
2775}
2776
2777/// Emit a solid-colour quad (6 vertices) in screen pixel coordinates.
2778fn emit_solid_quad(
2779    verts: &mut Vec<crate::resources::OverlayTextVertex>,
2780    x0: f32, y0: f32,
2781    x1: f32, y1: f32,
2782    color: [f32; 4],
2783    vp_w: f32, vp_h: f32,
2784) {
2785    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
2786    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
2787    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
2788    let br = px_to_ndc(x1, y1, vp_w, vp_h);
2789    let uv = [0.0, 0.0];
2790    let tex = 0.0;
2791    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
2792        position: pos, uv, color, use_texture: tex, _pad: 0.0,
2793    };
2794    verts.extend_from_slice(&[v(tl), v(bl), v(tr), v(tr), v(bl), v(br)]);
2795}
2796
2797/// Emit a textured quad (6 vertices) for a glyph in screen pixel coordinates.
2798fn emit_textured_quad(
2799    verts: &mut Vec<crate::resources::OverlayTextVertex>,
2800    x0: f32, y0: f32,
2801    x1: f32, y1: f32,
2802    uv_min: [f32; 2],
2803    uv_max: [f32; 2],
2804    color: [f32; 4],
2805    vp_w: f32, vp_h: f32,
2806) {
2807    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
2808    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
2809    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
2810    let br = px_to_ndc(x1, y1, vp_w, vp_h);
2811    let tex = 1.0;
2812    let v = |pos: [f32; 2], uv: [f32; 2]| crate::resources::OverlayTextVertex {
2813        position: pos, uv, color, use_texture: tex, _pad: 0.0,
2814    };
2815    // UV layout: top-left = uv_min, bottom-right = uv_max.
2816    verts.extend_from_slice(&[
2817        v(tl, uv_min),
2818        v(bl, [uv_min[0], uv_max[1]]),
2819        v(tr, [uv_max[0], uv_min[1]]),
2820        v(tr, [uv_max[0], uv_min[1]]),
2821        v(bl, [uv_min[0], uv_max[1]]),
2822        v(br, uv_max),
2823    ]);
2824}
2825
2826/// Emit a thin screen-space line as a quad (6 vertices).
2827fn emit_line_quad(
2828    verts: &mut Vec<crate::resources::OverlayTextVertex>,
2829    x0: f32, y0: f32,
2830    x1: f32, y1: f32,
2831    thickness: f32,
2832    color: [f32; 4],
2833    vp_w: f32, vp_h: f32,
2834) {
2835    let dx = x1 - x0;
2836    let dy = y1 - y0;
2837    let len = (dx * dx + dy * dy).sqrt();
2838    if len < 0.001 {
2839        return;
2840    }
2841    let half = thickness * 0.5;
2842    let nx = -dy / len * half;
2843    let ny = dx / len * half;
2844
2845    let p0 = px_to_ndc(x0 + nx, y0 + ny, vp_w, vp_h);
2846    let p1 = px_to_ndc(x0 - nx, y0 - ny, vp_w, vp_h);
2847    let p2 = px_to_ndc(x1 + nx, y1 + ny, vp_w, vp_h);
2848    let p3 = px_to_ndc(x1 - nx, y1 - ny, vp_w, vp_h);
2849    let uv = [0.0, 0.0];
2850    let tex = 0.0;
2851    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
2852        position: pos, uv, color, use_texture: tex, _pad: 0.0,
2853    };
2854    verts.extend_from_slice(&[v(p0), v(p1), v(p2), v(p2), v(p1), v(p3)]);
2855}
2856
2857/// Apply an opacity multiplier to a colour's alpha channel.
2858#[inline]
2859fn apply_opacity(color: [f32; 4], opacity: f32) -> [f32; 4] {
2860    [color[0], color[1], color[2], color[3] * opacity]
2861}
2862
2863/// Emit a rounded rectangle as solid quads: one center rect + four edge rects +
2864/// four corner fans.  This is a CPU tessellation approach that avoids shader
2865/// changes.
2866fn emit_rounded_quad(
2867    verts: &mut Vec<crate::resources::OverlayTextVertex>,
2868    x0: f32, y0: f32,
2869    x1: f32, y1: f32,
2870    radius: f32,
2871    color: [f32; 4],
2872    vp_w: f32, vp_h: f32,
2873) {
2874    let w = x1 - x0;
2875    let h = y1 - y0;
2876    let r = radius.min(w * 0.5).min(h * 0.5).max(0.0);
2877
2878    if r < 0.5 {
2879        emit_solid_quad(verts, x0, y0, x1, y1, color, vp_w, vp_h);
2880        return;
2881    }
2882
2883    // Center cross (two rects that cover everything except the corners).
2884    // Horizontal bar (full width, inset top/bottom by r).
2885    emit_solid_quad(verts, x0, y0 + r, x1, y1 - r, color, vp_w, vp_h);
2886    // Top bar (inset left/right by r, top edge).
2887    emit_solid_quad(verts, x0 + r, y0, x1 - r, y0 + r, color, vp_w, vp_h);
2888    // Bottom bar.
2889    emit_solid_quad(verts, x0 + r, y1 - r, x1 - r, y1, color, vp_w, vp_h);
2890
2891    // Four corner fans.
2892    let corners = [
2893        (x0 + r, y0 + r, std::f32::consts::PI, std::f32::consts::FRAC_PI_2 * 3.0),       // top-left
2894        (x1 - r, y0 + r, std::f32::consts::FRAC_PI_2 * 3.0, std::f32::consts::TAU),      // top-right
2895        (x1 - r, y1 - r, 0.0, std::f32::consts::FRAC_PI_2),                               // bottom-right
2896        (x0 + r, y1 - r, std::f32::consts::FRAC_PI_2, std::f32::consts::PI),              // bottom-left
2897    ];
2898    let segments = 6;
2899    let uv = [0.0, 0.0];
2900    let tex = 0.0;
2901    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
2902        position: pos, uv, color, use_texture: tex, _pad: 0.0,
2903    };
2904    for (cx, cy, start, end) in corners {
2905        let center = px_to_ndc(cx, cy, vp_w, vp_h);
2906        for i in 0..segments {
2907            let a0 = start + (end - start) * i as f32 / segments as f32;
2908            let a1 = start + (end - start) * (i + 1) as f32 / segments as f32;
2909            let p0 = px_to_ndc(cx + a0.cos() * r, cy + a0.sin() * r, vp_w, vp_h);
2910            let p1 = px_to_ndc(cx + a1.cos() * r, cy + a1.sin() * r, vp_w, vp_h);
2911            verts.extend_from_slice(&[v(center), v(p0), v(p1)]);
2912        }
2913    }
2914}
2915
2916// ---------------------------------------------------------------------------
2917// Ruler label formatting
2918// ---------------------------------------------------------------------------
2919
2920/// Format a distance value using a caller-supplied format pattern.
2921///
2922/// The pattern may contain one `{...}` placeholder with an optional precision
2923/// specifier, e.g. `"{:.3}"` or `"{:.2} m"`.  Anything outside the braces is
2924/// treated as a literal prefix / suffix.  Unrecognised patterns fall back to
2925/// three decimal places.
2926fn format_ruler_distance(distance: f32, fmt: Option<&str>) -> String {
2927    let pattern = fmt.unwrap_or("{:.3}");
2928    // Find the first `{...}` block.
2929    if let Some(open) = pattern.find('{') {
2930        if let Some(close_rel) = pattern[open..].find('}') {
2931            let close = open + close_rel;
2932            let spec = &pattern[open + 1..close]; // e.g. ":.3" or ""
2933            let prefix = &pattern[..open];
2934            let suffix = &pattern[close + 1..];
2935            let formatted = if let Some(prec_str) = spec.strip_prefix(":.") {
2936                // Strip trailing 'f' for patterns like "{:.3f}".
2937                let prec_str = prec_str.trim_end_matches('f');
2938                if let Ok(prec) = prec_str.parse::<usize>() {
2939                    format!("{distance:.prec$}")
2940                } else {
2941                    format!("{distance:.3}")
2942                }
2943            } else if spec.is_empty() || spec == ":" {
2944                format!("{distance}")
2945            } else {
2946                format!("{distance:.3}")
2947            };
2948            return format!("{prefix}{formatted}{suffix}");
2949        }
2950    }
2951    format!("{distance:.3}")
2952}