Skip to main content

viewport_lib/renderer/
prepare.rs

1use super::types::{ClipShape, SceneEffects, ViewportEffects};
2use super::*;
3
4impl ViewportRenderer {
5    /// Scene-global prepare stage: compute filters, lighting, shadow pass, batching, scivis.
6    ///
7    /// Call once per frame before any `prepare_viewport_internal` calls.
8    ///
9    /// Reads `scene_fx` for lighting, IBL, and compute filters.  Still reads
10    /// `frame.camera` for shadow cascade computation (Phase 1 coupling : see
11    /// multi-viewport-plan.md § shadow strategy; decoupled in Phase 2).
12    pub(super) fn prepare_scene_internal(
13        &mut self,
14        device: &wgpu::Device,
15        queue: &wgpu::Queue,
16        frame: &FrameData,
17        scene_fx: &SceneEffects<'_>,
18    ) {
19        // Phase G : GPU compute filtering.
20        // Dispatch before the render pass. Completely skipped when list is empty (zero overhead).
21        if !scene_fx.compute_filter_items.is_empty() {
22            self.compute_filter_results =
23                self.resources
24                    .run_compute_filters(device, queue, scene_fx.compute_filter_items);
25        } else {
26            self.compute_filter_results.clear();
27        }
28
29        // Ensure built-in colormaps and matcaps are uploaded on first frame.
30        self.resources.ensure_colormaps_initialized(device, queue);
31        self.resources.ensure_matcaps_initialized(device, queue);
32
33        let resources = &mut self.resources;
34        let lighting = scene_fx.lighting;
35
36        // Resolve scene items from the SurfaceSubmission seam.
37        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
38            SurfaceSubmission::Flat(items) => items,
39        };
40
41        // Compute scene center / extent for shadow framing.
42        let (shadow_center, shadow_extent) = if let Some(extent) = lighting.shadow_extent_override {
43            (glam::Vec3::ZERO, extent)
44        } else {
45            (glam::Vec3::ZERO, 20.0)
46        };
47
48        /// Build a light-space view-projection matrix for shadow mapping.
49        fn compute_shadow_matrix(
50            kind: &LightKind,
51            shadow_center: glam::Vec3,
52            shadow_extent: f32,
53        ) -> glam::Mat4 {
54            match kind {
55                LightKind::Directional { direction } => {
56                    let dir = glam::Vec3::from(*direction).normalize();
57                    let light_up = if dir.z.abs() > 0.99 {
58                        glam::Vec3::Y
59                    } else {
60                        glam::Vec3::Z
61                    };
62                    let light_pos = shadow_center + dir * shadow_extent * 2.0;
63                    let light_view = glam::Mat4::look_at_rh(light_pos, shadow_center, light_up);
64                    let light_proj = glam::Mat4::orthographic_rh(
65                        -shadow_extent,
66                        shadow_extent,
67                        -shadow_extent,
68                        shadow_extent,
69                        0.01,
70                        shadow_extent * 5.0,
71                    );
72                    light_proj * light_view
73                }
74                LightKind::Point { position, range } => {
75                    let pos = glam::Vec3::from(*position);
76                    let to_center = (shadow_center - pos).normalize();
77                    let light_up = if to_center.z.abs() > 0.99 {
78                        glam::Vec3::Y
79                    } else {
80                        glam::Vec3::Z
81                    };
82                    let light_view = glam::Mat4::look_at_rh(pos, shadow_center, light_up);
83                    let light_proj =
84                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
85                    light_proj * light_view
86                }
87                LightKind::Spot {
88                    position,
89                    direction,
90                    range,
91                    ..
92                } => {
93                    let pos = glam::Vec3::from(*position);
94                    let dir = glam::Vec3::from(*direction).normalize();
95                    let look_target = pos + dir;
96                    let up = if dir.z.abs() > 0.99 {
97                        glam::Vec3::Y
98                    } else {
99                        glam::Vec3::Z
100                    };
101                    let light_view = glam::Mat4::look_at_rh(pos, look_target, up);
102                    let light_proj =
103                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
104                    light_proj * light_view
105                }
106            }
107        }
108
109        /// Convert a `LightSource` to `SingleLightUniform`, computing shadow matrix for lights[0].
110        fn build_single_light_uniform(
111            src: &LightSource,
112            shadow_center: glam::Vec3,
113            shadow_extent: f32,
114            compute_shadow: bool,
115        ) -> SingleLightUniform {
116            let shadow_mat = if compute_shadow {
117                compute_shadow_matrix(&src.kind, shadow_center, shadow_extent)
118            } else {
119                glam::Mat4::IDENTITY
120            };
121
122            match &src.kind {
123                LightKind::Directional { direction } => SingleLightUniform {
124                    light_view_proj: shadow_mat.to_cols_array_2d(),
125                    pos_or_dir: *direction,
126                    light_type: 0,
127                    color: src.color,
128                    intensity: src.intensity,
129                    range: 0.0,
130                    inner_angle: 0.0,
131                    outer_angle: 0.0,
132                    _pad_align: 0,
133                    spot_direction: [0.0, -1.0, 0.0],
134                    _pad: [0.0; 5],
135                },
136                LightKind::Point { position, range } => SingleLightUniform {
137                    light_view_proj: shadow_mat.to_cols_array_2d(),
138                    pos_or_dir: *position,
139                    light_type: 1,
140                    color: src.color,
141                    intensity: src.intensity,
142                    range: *range,
143                    inner_angle: 0.0,
144                    outer_angle: 0.0,
145                    _pad_align: 0,
146                    spot_direction: [0.0, -1.0, 0.0],
147                    _pad: [0.0; 5],
148                },
149                LightKind::Spot {
150                    position,
151                    direction,
152                    range,
153                    inner_angle,
154                    outer_angle,
155                } => SingleLightUniform {
156                    light_view_proj: shadow_mat.to_cols_array_2d(),
157                    pos_or_dir: *position,
158                    light_type: 2,
159                    color: src.color,
160                    intensity: src.intensity,
161                    range: *range,
162                    inner_angle: *inner_angle,
163                    outer_angle: *outer_angle,
164                    _pad_align: 0,
165                    spot_direction: *direction,
166                    _pad: [0.0; 5],
167                },
168            }
169        }
170
171        // Build the LightsUniform for all active lights (max 8).
172        let light_count = lighting.lights.len().min(8) as u32;
173        let mut lights_arr = [SingleLightUniform {
174            light_view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
175            pos_or_dir: [0.0; 3],
176            light_type: 0,
177            color: [1.0; 3],
178            intensity: 1.0,
179            range: 0.0,
180            inner_angle: 0.0,
181            outer_angle: 0.0,
182            _pad_align: 0,
183            spot_direction: [0.0, -1.0, 0.0],
184            _pad: [0.0; 5],
185        }; 8];
186
187        for (i, src) in lighting.lights.iter().take(8).enumerate() {
188            lights_arr[i] = build_single_light_uniform(src, shadow_center, shadow_extent, i == 0);
189        }
190
191        // -------------------------------------------------------------------
192        // Compute CSM cascade matrices for lights[0] (directional).
193        // Phase 1 note: uses frame.camera : see multi-viewport-plan.md § shadow strategy.
194        // -------------------------------------------------------------------
195        let cascade_count = lighting.shadow_cascade_count.clamp(1, 4) as usize;
196        let atlas_res = lighting.shadow_atlas_resolution.max(64);
197        let tile_size = atlas_res / 2;
198
199        let cascade_splits = compute_cascade_splits(
200            frame.camera.render_camera.near.max(0.01),
201            frame.camera.render_camera.far.max(1.0),
202            cascade_count as u32,
203            lighting.cascade_split_lambda,
204        );
205
206        let light_dir_for_csm = if light_count > 0 {
207            match &lighting.lights[0].kind {
208                LightKind::Directional { direction } => glam::Vec3::from(*direction).normalize(),
209                LightKind::Point { position, .. } => {
210                    (glam::Vec3::from(*position) - shadow_center).normalize()
211                }
212                LightKind::Spot {
213                    position,
214                    direction,
215                    ..
216                } => {
217                    let _ = position;
218                    glam::Vec3::from(*direction).normalize()
219                }
220            }
221        } else {
222            glam::Vec3::new(0.3, 1.0, 0.5).normalize()
223        };
224
225        let mut cascade_view_projs = [glam::Mat4::IDENTITY; 4];
226        // Distance-based splits for fragment shader cascade selection.
227        let mut cascade_split_distances = [0.0f32; 4];
228
229        // Determine if we should use CSM (directional light + valid camera data).
230        let use_csm = light_count > 0
231            && matches!(lighting.lights[0].kind, LightKind::Directional { .. })
232            && frame.camera.render_camera.view != glam::Mat4::IDENTITY;
233
234        if use_csm {
235            for i in 0..cascade_count {
236                let split_near = if i == 0 {
237                    frame.camera.render_camera.near.max(0.01)
238                } else {
239                    cascade_splits[i - 1]
240                };
241                let split_far = cascade_splits[i];
242                cascade_view_projs[i] = compute_cascade_matrix(
243                    light_dir_for_csm,
244                    frame.camera.render_camera.view,
245                    frame.camera.render_camera.fov,
246                    frame.camera.render_camera.aspect,
247                    split_near,
248                    split_far,
249                    tile_size as f32,
250                );
251                cascade_split_distances[i] = split_far;
252            }
253        } else {
254            // Fallback: single shadow map covering the whole scene (legacy behavior).
255            let primary_shadow_mat = if light_count > 0 {
256                compute_shadow_matrix(&lighting.lights[0].kind, shadow_center, shadow_extent)
257            } else {
258                glam::Mat4::IDENTITY
259            };
260            cascade_view_projs[0] = primary_shadow_mat;
261            cascade_split_distances[0] = frame.camera.render_camera.far;
262        }
263        let effective_cascade_count = if use_csm { cascade_count } else { 1 };
264
265        // Atlas tile layout (2x2 grid):
266        // [0] = top-left, [1] = top-right, [2] = bottom-left, [3] = bottom-right
267        let atlas_rects: [[f32; 4]; 8] = [
268            [0.0, 0.0, 0.5, 0.5], // cascade 0
269            [0.5, 0.0, 1.0, 0.5], // cascade 1
270            [0.0, 0.5, 0.5, 1.0], // cascade 2
271            [0.5, 0.5, 1.0, 1.0], // cascade 3
272            [0.0; 4],
273            [0.0; 4],
274            [0.0; 4],
275            [0.0; 4], // unused slots
276        ];
277
278        // Upload ShadowAtlasUniform (binding 5).
279        {
280            let mut vp_data = [[0.0f32; 4]; 16]; // 4 mat4s flattened
281            for c in 0..4 {
282                let cols = cascade_view_projs[c].to_cols_array_2d();
283                for row in 0..4 {
284                    vp_data[c * 4 + row] = cols[row];
285                }
286            }
287            let shadow_atlas_uniform = ShadowAtlasUniform {
288                cascade_view_proj: vp_data,
289                cascade_splits: cascade_split_distances,
290                cascade_count: effective_cascade_count as u32,
291                atlas_size: atlas_res as f32,
292                shadow_filter: match lighting.shadow_filter {
293                    ShadowFilter::Pcf => 0,
294                    ShadowFilter::Pcss => 1,
295                },
296                pcss_light_radius: lighting.pcss_light_radius,
297                atlas_rects,
298            };
299            queue.write_buffer(
300                &resources.shadow_info_buf,
301                0,
302                bytemuck::cast_slice(&[shadow_atlas_uniform]),
303            );
304            // Write to all per-viewport slot buffers so each viewport's bind group
305            // references correctly populated shadow info.
306            for slot in &self.viewport_slots {
307                queue.write_buffer(
308                    &slot.shadow_info_buf,
309                    0,
310                    bytemuck::cast_slice(&[shadow_atlas_uniform]),
311                );
312            }
313        }
314
315        // The primary shadow matrix is still stored in lights[0].light_view_proj for
316        // backward compat with the non-instanced shadow pass uniform.
317        let _primary_shadow_mat = cascade_view_projs[0];
318        // Cache for ground plane ShadowOnly mode.
319        self.last_cascade0_shadow_mat = cascade_view_projs[0];
320
321        // Upload lights uniform.
322        // IBL fields from environment map settings.
323        let (ibl_enabled, ibl_intensity, ibl_rotation, show_skybox) =
324            if let Some(env) = scene_fx.environment {
325                if resources.ibl_irradiance_view.is_some() {
326                    (
327                        1u32,
328                        env.intensity,
329                        env.rotation,
330                        if env.show_skybox { 1u32 } else { 0 },
331                    )
332                } else {
333                    (0, 0.0, 0.0, 0)
334                }
335            } else {
336                (0, 0.0, 0.0, 0)
337            };
338
339        let lights_uniform = LightsUniform {
340            count: light_count,
341            shadow_bias: lighting.shadow_bias,
342            shadows_enabled: if lighting.shadows_enabled { 1 } else { 0 },
343            _pad: 0,
344            sky_color: lighting.sky_color,
345            hemisphere_intensity: lighting.hemisphere_intensity,
346            ground_color: lighting.ground_color,
347            _pad2: 0.0,
348            lights: lights_arr,
349            ibl_enabled,
350            ibl_intensity,
351            ibl_rotation,
352            show_skybox,
353        };
354        queue.write_buffer(
355            &resources.light_uniform_buf,
356            0,
357            bytemuck::cast_slice(&[lights_uniform]),
358        );
359
360        // Upload all cascade matrices to the shadow uniform buffer before the shadow pass.
361        // wgpu batches write_buffer calls before the command buffer, so we must write ALL
362        // cascade slots up-front; the cascade loop then selects per-slot via dynamic offset.
363        const SHADOW_SLOT_STRIDE: u64 = 256;
364        for c in 0..4usize {
365            queue.write_buffer(
366                &resources.shadow_uniform_buf,
367                c as u64 * SHADOW_SLOT_STRIDE,
368                bytemuck::cast_slice(&cascade_view_projs[c].to_cols_array_2d()),
369            );
370        }
371
372        // -- Instancing preparation --
373        // Determine instancing mode BEFORE per-object uniforms so we can skip them.
374        let visible_count = scene_items.iter().filter(|i| i.visible).count();
375        let prev_use_instancing = self.use_instancing;
376        self.use_instancing = visible_count > INSTANCING_THRESHOLD;
377
378        // If instancing mode changed (e.g. objects added/removed crossing the threshold),
379        // clear batches so the generation check below forces a rebuild.
380        if self.use_instancing != prev_use_instancing {
381            self.instanced_batches.clear();
382            self.last_scene_generation = u64::MAX;
383            self.last_scene_items_count = usize::MAX;
384        }
385
386        // Per-object uniform writes : needed for the non-instanced path, wireframe mode,
387        // and for any items with active scalar attributes or two-sided materials
388        // (both bypass the instanced path).
389        let has_scalar_items = scene_items.iter().any(|i| i.active_attribute.is_some());
390        let has_two_sided_items = scene_items
391            .iter()
392            .any(|i| i.material.is_two_sided());
393        let has_matcap_items = scene_items.iter().any(|i| i.material.matcap_id.is_some());
394        let has_param_vis_items = scene_items.iter().any(|i| i.material.param_vis.is_some());
395        let has_wireframe_items = scene_items.iter().any(|i| i.render_as_wireframe);
396        if !self.use_instancing
397            || frame.viewport.wireframe_mode
398            || has_scalar_items
399            || has_two_sided_items
400            || has_matcap_items
401            || has_param_vis_items
402            || has_wireframe_items
403        {
404            for item in scene_items {
405                if resources
406                    .mesh_store
407                    .get(item.mesh_id)
408                    .is_none()
409                {
410                    tracing::warn!(
411                        mesh_index = item.mesh_id.index(),
412                        "scene item mesh_index invalid, skipping"
413                    );
414                    continue;
415                };
416                let m = &item.material;
417                // Compute scalar attribute range.
418                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
419                    let range = item
420                        .scalar_range
421                        .or_else(|| {
422                            resources
423                                .mesh_store
424                                .get(item.mesh_id)
425                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
426                        })
427                        .unwrap_or((0.0, 1.0));
428                    (1u32, range.0, range.1)
429                } else {
430                    (0u32, 0.0, 1.0)
431                };
432                let obj_uniform = ObjectUniform {
433                    model: item.model,
434                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
435                    selected: if item.selected { 1 } else { 0 },
436                    wireframe: if frame.viewport.wireframe_mode || item.render_as_wireframe { 1 } else { 0 },
437                    ambient: m.ambient,
438                    diffuse: m.diffuse,
439                    specular: m.specular,
440                    shininess: m.shininess,
441                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
442                    use_pbr: if m.use_pbr { 1 } else { 0 },
443                    metallic: m.metallic,
444                    roughness: m.roughness,
445                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
446                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
447                    has_attribute: has_attr,
448                    scalar_min: s_min,
449                    scalar_max: s_max,
450                    _pad_scalar: 0,
451                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
452                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
453                    use_matcap: if m.matcap_id.is_some() { 1 } else { 0 },
454                    matcap_blendable: m.matcap_id.map_or(0, |id| if id.blendable { 1 } else { 0 }),
455                    _pad2: 0,
456                    use_face_color: u32::from(item.active_attribute.as_ref().map_or(false, |a| {
457                        a.kind == crate::resources::AttributeKind::FaceColor
458                    })),
459                    uv_vis_mode: m.param_vis.map_or(0, |pv| pv.mode as u32),
460                    uv_vis_scale: m.param_vis.map_or(8.0, |pv| pv.scale),
461                    backface_policy: match m.backface_policy {
462                        crate::scene::material::BackfacePolicy::Cull => 0,
463                        crate::scene::material::BackfacePolicy::Identical => 1,
464                        crate::scene::material::BackfacePolicy::DifferentColor(_) => 2,
465                        crate::scene::material::BackfacePolicy::Tint(_) => 3,
466                        crate::scene::material::BackfacePolicy::Pattern { pattern, .. } => {
467                            4 + pattern as u32
468                        }
469                    },
470                    backface_color: match m.backface_policy {
471                        crate::scene::material::BackfacePolicy::DifferentColor(c) => {
472                            [c[0], c[1], c[2], 1.0]
473                        }
474                        crate::scene::material::BackfacePolicy::Tint(factor) => {
475                            [factor, 0.0, 0.0, 1.0]
476                        }
477                        crate::scene::material::BackfacePolicy::Pattern { color, .. } => {
478                            [color[0], color[1], color[2], 1.0]
479                        }
480                        _ => [0.0; 4],
481                    },
482                };
483
484                let normal_obj_uniform = ObjectUniform {
485                    model: item.model,
486                    color: [1.0, 1.0, 1.0, 1.0],
487                    selected: 0,
488                    wireframe: 0,
489                    ambient: 0.15,
490                    diffuse: 0.75,
491                    specular: 0.4,
492                    shininess: 32.0,
493                    has_texture: 0,
494                    use_pbr: 0,
495                    metallic: 0.0,
496                    roughness: 0.5,
497                    has_normal_map: 0,
498                    has_ao_map: 0,
499                    has_attribute: 0,
500                    scalar_min: 0.0,
501                    scalar_max: 1.0,
502                    _pad_scalar: 0,
503                    nan_color: [0.0; 4],
504                    use_nan_color: 0,
505                    use_matcap: 0,
506                    matcap_blendable: 0,
507                    _pad2: 0,
508                    use_face_color: 0,
509                    uv_vis_mode: 0,
510                    uv_vis_scale: 8.0,
511                    backface_policy: 0,
512                    backface_color: [0.0; 4],
513                };
514
515                // Write uniform data : use get() to read buffer references, then drop.
516                {
517                    let mesh = resources
518                        .mesh_store
519                        .get(item.mesh_id)
520                        .unwrap();
521                    queue.write_buffer(
522                        &mesh.object_uniform_buf,
523                        0,
524                        bytemuck::cast_slice(&[obj_uniform]),
525                    );
526                    queue.write_buffer(
527                        &mesh.normal_uniform_buf,
528                        0,
529                        bytemuck::cast_slice(&[normal_obj_uniform]),
530                    );
531                } // mesh borrow dropped here
532
533                // Rebuild the object bind group if material/attribute/LUT/matcap changed.
534                resources.update_mesh_texture_bind_group(
535                    device,
536                    item.mesh_id,
537                    item.material.texture_id,
538                    item.material.normal_map_id,
539                    item.material.ao_map_id,
540                    item.colormap_id,
541                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
542                    item.material.matcap_id,
543                );
544            }
545        }
546
547        if self.use_instancing {
548            resources.ensure_instanced_pipelines(device);
549
550            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
551            // Phase 2: wireframe_mode removed from cache key : wireframe rendering
552            // uses the per-object wireframe_pipeline, not the instanced path, so
553            // instance data is now viewport-agnostic.
554            let cache_valid = frame.scene.generation == self.last_scene_generation
555                && frame.interaction.selection_generation == self.last_selection_generation
556                && scene_items.len() == self.last_scene_items_count;
557
558            if !cache_valid {
559                // Cache miss : rebuild batches and upload instance data.
560                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
561                    .iter()
562                    .filter(|item| {
563                        item.visible
564                            && item.active_attribute.is_none()
565                            && !item.material.is_two_sided()
566                            && item.material.matcap_id.is_none()
567                            && item.material.param_vis.is_none()
568                            && resources
569                                .mesh_store
570                                .get(item.mesh_id)
571                                .is_some()
572                    })
573                    .collect();
574
575                sorted_items.sort_unstable_by_key(|item| {
576                    (
577                        item.mesh_id.index(),
578                        item.material.texture_id,
579                        item.material.normal_map_id,
580                        item.material.ao_map_id,
581                    )
582                });
583
584                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
585                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
586
587                if !sorted_items.is_empty() {
588                    let mut batch_start = 0usize;
589                    for i in 1..=sorted_items.len() {
590                        let at_end = i == sorted_items.len();
591                        let key_changed = !at_end && {
592                            let a = sorted_items[batch_start];
593                            let b = sorted_items[i];
594                            a.mesh_id != b.mesh_id
595                                || a.material.texture_id != b.material.texture_id
596                                || a.material.normal_map_id != b.material.normal_map_id
597                                || a.material.ao_map_id != b.material.ao_map_id
598                        };
599
600                        if at_end || key_changed {
601                            let batch_items = &sorted_items[batch_start..i];
602                            let rep = batch_items[0];
603                            let instance_offset = all_instances.len() as u32;
604                            let is_transparent = rep.material.opacity < 1.0;
605
606                            for item in batch_items {
607                                let m = &item.material;
608                                all_instances.push(InstanceData {
609                                    model: item.model,
610                                    color: [
611                                        m.base_color[0],
612                                        m.base_color[1],
613                                        m.base_color[2],
614                                        m.opacity,
615                                    ],
616                                    selected: if item.selected { 1 } else { 0 },
617                                    wireframe: 0, // Phase 2: always 0 : wireframe uses per-object pipeline
618                                    ambient: m.ambient,
619                                    diffuse: m.diffuse,
620                                    specular: m.specular,
621                                    shininess: m.shininess,
622                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
623                                    use_pbr: if m.use_pbr { 1 } else { 0 },
624                                    metallic: m.metallic,
625                                    roughness: m.roughness,
626                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
627                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
628                                });
629                            }
630
631                            instanced_batches.push(InstancedBatch {
632                                mesh_id: rep.mesh_id,
633                                texture_id: rep.material.texture_id,
634                                normal_map_id: rep.material.normal_map_id,
635                                ao_map_id: rep.material.ao_map_id,
636                                instance_offset,
637                                instance_count: batch_items.len() as u32,
638                                is_transparent,
639                            });
640
641                            batch_start = i;
642                        }
643                    }
644                }
645
646                self.cached_instance_data = all_instances;
647                self.cached_instanced_batches = instanced_batches;
648
649                resources.upload_instance_data(device, queue, &self.cached_instance_data);
650
651                self.instanced_batches = self.cached_instanced_batches.clone();
652
653                self.last_scene_generation = frame.scene.generation;
654                self.last_selection_generation = frame.interaction.selection_generation;
655                self.last_scene_items_count = scene_items.len();
656
657                for batch in &self.instanced_batches {
658                    resources.get_instance_bind_group(
659                        device,
660                        batch.texture_id,
661                        batch.normal_map_id,
662                        batch.ao_map_id,
663                    );
664                }
665            } else {
666                for batch in &self.instanced_batches {
667                    resources.get_instance_bind_group(
668                        device,
669                        batch.texture_id,
670                        batch.normal_map_id,
671                        batch.ao_map_id,
672                    );
673                }
674            }
675        }
676
677        // ------------------------------------------------------------------
678        // SciVis Phase B : point cloud and glyph GPU data upload.
679        // ------------------------------------------------------------------
680        self.point_cloud_gpu_data.clear();
681        if !frame.scene.point_clouds.is_empty() {
682            resources.ensure_point_cloud_pipeline(device);
683            for item in &frame.scene.point_clouds {
684                if item.positions.is_empty() {
685                    continue;
686                }
687                let gpu_data = resources.upload_point_cloud(device, queue, item);
688                self.point_cloud_gpu_data.push(gpu_data);
689            }
690        }
691
692        self.glyph_gpu_data.clear();
693        if !frame.scene.glyphs.is_empty() {
694            resources.ensure_glyph_pipeline(device);
695            for item in &frame.scene.glyphs {
696                if item.positions.is_empty() || item.vectors.is_empty() {
697                    continue;
698                }
699                let gpu_data = resources.upload_glyph_set(device, queue, item);
700                self.glyph_gpu_data.push(gpu_data);
701            }
702        }
703
704        // ------------------------------------------------------------------
705        // SciVis Phase M8 : polyline GPU data upload.
706        // ------------------------------------------------------------------
707        self.polyline_gpu_data.clear();
708        let vp_size = frame.camera.viewport_size;
709        if !frame.scene.polylines.is_empty() {
710            resources.ensure_polyline_pipeline(device);
711            for item in &frame.scene.polylines {
712                if item.positions.is_empty() {
713                    continue;
714                }
715                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
716                self.polyline_gpu_data.push(gpu_data);
717
718                // Phase 11: auto-generate GlyphItems for node/edge vector quantities.
719                if !item.node_vectors.is_empty() {
720                    resources.ensure_glyph_pipeline(device);
721                    let g = crate::quantities::polyline_node_vectors_to_glyphs(item);
722                    if !g.positions.is_empty() {
723                        let gd = resources.upload_glyph_set(device, queue, &g);
724                        self.glyph_gpu_data.push(gd);
725                    }
726                }
727                if !item.edge_vectors.is_empty() {
728                    resources.ensure_glyph_pipeline(device);
729                    let g = crate::quantities::polyline_edge_vectors_to_glyphs(item);
730                    if !g.positions.is_empty() {
731                        let gd = resources.upload_glyph_set(device, queue, &g);
732                        self.glyph_gpu_data.push(gd);
733                    }
734                }
735            }
736        }
737
738        // ------------------------------------------------------------------
739        // SciVis Phase L : isoline extraction and upload via polyline pipeline.
740        // ------------------------------------------------------------------
741        if !frame.scene.isolines.is_empty() {
742            resources.ensure_polyline_pipeline(device);
743            for item in &frame.scene.isolines {
744                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
745                    continue;
746                }
747                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
748                if positions.is_empty() {
749                    continue;
750                }
751                let polyline = PolylineItem {
752                    positions,
753                    scalars: Vec::new(),
754                    strip_lengths,
755                    scalar_range: None,
756                    colormap_id: None,
757                    default_color: item.color,
758                    line_width: item.line_width,
759                    id: 0,
760                    ..Default::default()
761                };
762                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
763                self.polyline_gpu_data.push(gpu_data);
764            }
765        }
766
767        // ------------------------------------------------------------------
768        // Phase 10A : camera frustum wireframes (converted to polylines).
769        // ------------------------------------------------------------------
770        if !frame.scene.camera_frustums.is_empty() {
771            resources.ensure_polyline_pipeline(device);
772            for item in &frame.scene.camera_frustums {
773                let polyline = item.to_polyline();
774                if !polyline.positions.is_empty() {
775                    let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
776                    self.polyline_gpu_data.push(gpu_data);
777                }
778            }
779        }
780
781        // ------------------------------------------------------------------
782        // Phase 16 : GPU implicit surface items.
783        // ------------------------------------------------------------------
784        self.implicit_gpu_data.clear();
785        if !frame.scene.gpu_implicit.is_empty() {
786            resources.ensure_implicit_pipeline(device);
787            for item in &frame.scene.gpu_implicit {
788                if item.primitives.is_empty() {
789                    continue;
790                }
791                let gpu = resources.upload_implicit_item(device, item);
792                self.implicit_gpu_data.push(gpu);
793            }
794        }
795
796        // ------------------------------------------------------------------
797        // Phase 17 : GPU marching cubes compute dispatch.
798        // ------------------------------------------------------------------
799        self.mc_gpu_data.clear();
800        if !frame.scene.gpu_mc_jobs.is_empty() {
801            resources.ensure_mc_pipelines(device);
802            self.mc_gpu_data =
803                resources.run_mc_jobs(device, queue, &frame.scene.gpu_mc_jobs);
804        }
805
806        // ------------------------------------------------------------------
807        // Phase 10B : screen-space image overlays.
808        // ------------------------------------------------------------------
809        self.screen_image_gpu_data.clear();
810        if !frame.scene.screen_images.is_empty() {
811            resources.ensure_screen_image_pipeline(device);
812            // Phase 12: ensure dc pipeline if any item carries depth data.
813            if frame.scene.screen_images.iter().any(|i| i.depth.is_some()) {
814                resources.ensure_screen_image_dc_pipeline(device);
815            }
816            let vp_w = vp_size[0];
817            let vp_h = vp_size[1];
818            for item in &frame.scene.screen_images {
819                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
820                    continue;
821                }
822                let gpu = resources.upload_screen_image(device, queue, item, vp_w, vp_h);
823                self.screen_image_gpu_data.push(gpu);
824            }
825        }
826
827        // ------------------------------------------------------------------
828        // SciVis Phase M : streamtube GPU data upload.
829        // ------------------------------------------------------------------
830        self.streamtube_gpu_data.clear();
831        if !frame.scene.streamtube_items.is_empty() {
832            resources.ensure_streamtube_pipeline(device);
833            for item in &frame.scene.streamtube_items {
834                if item.positions.is_empty() || item.strip_lengths.is_empty() {
835                    continue;
836                }
837                let gpu_data = resources.upload_streamtube(device, queue, item);
838                if gpu_data.index_count > 0 {
839                    self.streamtube_gpu_data.push(gpu_data);
840                }
841            }
842        }
843
844        // ------------------------------------------------------------------
845        // SciVis Phase D : volume GPU data upload.
846        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
847        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
848        // ------------------------------------------------------------------
849        self.volume_gpu_data.clear();
850        if !frame.scene.volumes.is_empty() {
851            resources.ensure_volume_pipeline(device);
852            // Extract ClipPlane structs from clip_objects for volume cap fill support.
853            let clip_planes_for_vol: Vec<crate::renderer::types::ClipPlane> = frame
854                .effects
855                .clip_objects
856                .iter()
857                .filter(|o| o.enabled)
858                .filter_map(|o| {
859                    if let ClipShape::Plane {
860                        normal,
861                        distance,
862                        cap_color,
863                    } = o.shape
864                    {
865                        Some(crate::renderer::types::ClipPlane {
866                            normal,
867                            distance,
868                            enabled: true,
869                            cap_color,
870                        })
871                    } else {
872                        None
873                    }
874                })
875                .collect();
876            for item in &frame.scene.volumes {
877                let gpu = resources.upload_volume_frame(device, queue, item, &clip_planes_for_vol);
878                self.volume_gpu_data.push(gpu);
879            }
880        }
881
882        // -- Frame stats --
883        {
884            let total = scene_items.len() as u32;
885            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
886            let mut draw_calls = 0u32;
887            let mut triangles = 0u64;
888            let instanced_batch_count = if self.use_instancing {
889                self.instanced_batches.len() as u32
890            } else {
891                0
892            };
893
894            if self.use_instancing {
895                for batch in &self.instanced_batches {
896                    if let Some(mesh) = resources
897                        .mesh_store
898                        .get(batch.mesh_id)
899                    {
900                        draw_calls += 1;
901                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
902                    }
903                }
904            } else {
905                for item in scene_items {
906                    if !item.visible {
907                        continue;
908                    }
909                    if let Some(mesh) = resources
910                        .mesh_store
911                        .get(item.mesh_id)
912                    {
913                        draw_calls += 1;
914                        triangles += (mesh.index_count / 3) as u64;
915                    }
916                }
917            }
918
919            self.last_stats = crate::renderer::stats::FrameStats {
920                total_objects: total,
921                visible_objects: visible,
922                culled_objects: total.saturating_sub(visible),
923                draw_calls,
924                instanced_batches: instanced_batch_count,
925                triangles_submitted: triangles,
926                shadow_draw_calls: 0, // Updated below in shadow pass.
927            };
928        }
929
930        // ------------------------------------------------------------------
931        // Shadow depth pass : CSM: render each cascade into its atlas tile.
932        // ------------------------------------------------------------------
933        if lighting.shadows_enabled && !scene_items.is_empty() {
934            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
935                label: Some("shadow_pass_encoder"),
936            });
937            {
938                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
939                    label: Some("shadow_pass"),
940                    color_attachments: &[],
941                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
942                        view: &resources.shadow_map_view,
943                        depth_ops: Some(wgpu::Operations {
944                            load: wgpu::LoadOp::Clear(1.0),
945                            store: wgpu::StoreOp::Store,
946                        }),
947                        stencil_ops: None,
948                    }),
949                    timestamp_writes: None,
950                    occlusion_query_set: None,
951                });
952
953                let mut shadow_draws = 0u32;
954                let tile_px = tile_size as f32;
955
956                if self.use_instancing {
957                    if let (Some(pipeline), Some(instance_bg)) = (
958                        &resources.shadow_instanced_pipeline,
959                        self.instanced_batches.first().and_then(|b| {
960                            resources.instance_bind_groups.get(&(
961                                b.texture_id.unwrap_or(u64::MAX),
962                                b.normal_map_id.unwrap_or(u64::MAX),
963                                b.ao_map_id.unwrap_or(u64::MAX),
964                            ))
965                        }),
966                    ) {
967                        for cascade in 0..effective_cascade_count {
968                            let tile_col = (cascade % 2) as f32;
969                            let tile_row = (cascade / 2) as f32;
970                            shadow_pass.set_viewport(
971                                tile_col * tile_px,
972                                tile_row * tile_px,
973                                tile_px,
974                                tile_px,
975                                0.0,
976                                1.0,
977                            );
978                            shadow_pass.set_scissor_rect(
979                                (tile_col * tile_px) as u32,
980                                (tile_row * tile_px) as u32,
981                                tile_size,
982                                tile_size,
983                            );
984
985                            shadow_pass.set_pipeline(pipeline);
986
987                            queue.write_buffer(
988                                resources.shadow_instanced_cascade_bufs[cascade]
989                                    .as_ref()
990                                    .expect("shadow_instanced_cascade_bufs not allocated"),
991                                0,
992                                bytemuck::cast_slice(
993                                    &cascade_view_projs[cascade].to_cols_array_2d(),
994                                ),
995                            );
996
997                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
998                                .as_ref()
999                                .expect("shadow_instanced_cascade_bgs not allocated");
1000                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1001                            shadow_pass.set_bind_group(1, instance_bg, &[]);
1002
1003                            for batch in &self.instanced_batches {
1004                                if batch.is_transparent {
1005                                    continue;
1006                                }
1007                                let Some(mesh) = resources
1008                                    .mesh_store
1009                                    .get(batch.mesh_id)
1010                                else {
1011                                    continue;
1012                                };
1013                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1014                                shadow_pass.set_index_buffer(
1015                                    mesh.index_buffer.slice(..),
1016                                    wgpu::IndexFormat::Uint32,
1017                                );
1018                                shadow_pass.draw_indexed(
1019                                    0..mesh.index_count,
1020                                    0,
1021                                    batch.instance_offset
1022                                        ..batch.instance_offset + batch.instance_count,
1023                                );
1024                                shadow_draws += 1;
1025                            }
1026                        }
1027                    }
1028                } else {
1029                    for cascade in 0..effective_cascade_count {
1030                        let tile_col = (cascade % 2) as f32;
1031                        let tile_row = (cascade / 2) as f32;
1032                        shadow_pass.set_viewport(
1033                            tile_col * tile_px,
1034                            tile_row * tile_px,
1035                            tile_px,
1036                            tile_px,
1037                            0.0,
1038                            1.0,
1039                        );
1040                        shadow_pass.set_scissor_rect(
1041                            (tile_col * tile_px) as u32,
1042                            (tile_row * tile_px) as u32,
1043                            tile_size,
1044                            tile_size,
1045                        );
1046
1047                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
1048                        shadow_pass.set_bind_group(
1049                            0,
1050                            &resources.shadow_bind_group,
1051                            &[cascade as u32 * 256],
1052                        );
1053
1054                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
1055                            &cascade_view_projs[cascade],
1056                        );
1057
1058                        for item in scene_items.iter() {
1059                            if !item.visible {
1060                                continue;
1061                            }
1062                            if item.material.opacity < 1.0 {
1063                                continue;
1064                            }
1065                            let Some(mesh) = resources
1066                                .mesh_store
1067                                .get(item.mesh_id)
1068                            else {
1069                                continue;
1070                            };
1071
1072                            let world_aabb = mesh
1073                                .aabb
1074                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1075                            if cascade_frustum.cull_aabb(&world_aabb) {
1076                                continue;
1077                            }
1078
1079                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1080                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1081                            shadow_pass.set_index_buffer(
1082                                mesh.index_buffer.slice(..),
1083                                wgpu::IndexFormat::Uint32,
1084                            );
1085                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1086                            shadow_draws += 1;
1087                        }
1088                    }
1089                }
1090                drop(shadow_pass);
1091                self.last_stats.shadow_draw_calls = shadow_draws;
1092            }
1093            queue.submit(std::iter::once(encoder.finish()));
1094        }
1095    }
1096
1097    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
1098    ///
1099    /// Call once per viewport per frame, after `prepare_scene_internal`.
1100    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
1101    pub(super) fn prepare_viewport_internal(
1102        &mut self,
1103        device: &wgpu::Device,
1104        queue: &wgpu::Queue,
1105        frame: &FrameData,
1106        viewport_fx: &ViewportEffects<'_>,
1107    ) {
1108        // Ensure a per-viewport camera slot exists for this viewport index.
1109        // Must happen before the `resources` borrow below.
1110        self.ensure_viewport_slot(device, frame.camera.viewport_index);
1111
1112        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1113            SurfaceSubmission::Flat(items) => items,
1114        };
1115
1116        // Capture before the resources mutable borrow so it's accessible inside the block.
1117        let gp_cascade0_mat = self.last_cascade0_shadow_mat.to_cols_array_2d();
1118
1119        {
1120            let resources = &mut self.resources;
1121
1122            // Upload clip planes + clip volume uniforms from clip_objects.
1123            {
1124                let mut planes = [[0.0f32; 4]; 6];
1125                let mut count = 0u32;
1126                let mut clip_vol_uniform: ClipVolumeUniform = bytemuck::Zeroable::zeroed(); // volume_type=0
1127
1128                for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1129                    match obj.shape {
1130                        ClipShape::Plane {
1131                            normal, distance, ..
1132                        } if count < 6 => {
1133                            planes[count as usize] = [normal[0], normal[1], normal[2], distance];
1134                            count += 1;
1135                        }
1136                        ClipShape::Box {
1137                            center,
1138                            half_extents,
1139                            orientation,
1140                        } if clip_vol_uniform.volume_type == 0 => {
1141                            clip_vol_uniform.volume_type = 2;
1142                            clip_vol_uniform.box_center = center;
1143                            clip_vol_uniform.box_half_extents = half_extents;
1144                            clip_vol_uniform.box_col0 = orientation[0];
1145                            clip_vol_uniform.box_col1 = orientation[1];
1146                            clip_vol_uniform.box_col2 = orientation[2];
1147                        }
1148                        ClipShape::Sphere { center, radius }
1149                            if clip_vol_uniform.volume_type == 0 =>
1150                        {
1151                            clip_vol_uniform.volume_type = 3;
1152                            clip_vol_uniform.sphere_center = center;
1153                            clip_vol_uniform.sphere_radius = radius;
1154                        }
1155                        _ => {}
1156                    }
1157                }
1158
1159                let clip_uniform = ClipPlanesUniform {
1160                    planes,
1161                    count,
1162                    _pad0: 0,
1163                    viewport_width: frame.camera.viewport_size[0].max(1.0),
1164                    viewport_height: frame.camera.viewport_size[1].max(1.0),
1165                };
1166                // Write to per-viewport slot buffer.
1167                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1168                    queue.write_buffer(
1169                        &slot.clip_planes_buf,
1170                        0,
1171                        bytemuck::cast_slice(&[clip_uniform]),
1172                    );
1173                    queue.write_buffer(
1174                        &slot.clip_volume_buf,
1175                        0,
1176                        bytemuck::cast_slice(&[clip_vol_uniform]),
1177                    );
1178                }
1179                // Also write to shared buffers for legacy single-viewport callers.
1180                queue.write_buffer(
1181                    &resources.clip_planes_uniform_buf,
1182                    0,
1183                    bytemuck::cast_slice(&[clip_uniform]),
1184                );
1185                queue.write_buffer(
1186                    &resources.clip_volume_uniform_buf,
1187                    0,
1188                    bytemuck::cast_slice(&[clip_vol_uniform]),
1189                );
1190            }
1191
1192            // Upload camera uniform to per-viewport slot buffer.
1193            let camera_uniform = frame.camera.render_camera.camera_uniform();
1194            // Write to shared buffer for legacy single-viewport callers.
1195            queue.write_buffer(
1196                &resources.camera_uniform_buf,
1197                0,
1198                bytemuck::cast_slice(&[camera_uniform]),
1199            );
1200            // Write to the per-viewport slot buffer.
1201            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1202                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1203            }
1204
1205            // Upload grid uniform (full-screen analytical shader : no vertex buffers needed).
1206            if frame.viewport.show_grid {
1207                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1208                if !eye.is_finite() {
1209                    tracing::warn!(
1210                        eye_x = eye.x,
1211                        eye_y = eye.y,
1212                        eye_z = eye.z,
1213                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1214                    );
1215                } else {
1216                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1217
1218                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1219                        (frame.viewport.grid_cell_size, 1.0_f32)
1220                    } else {
1221                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1222                        let world_per_pixel =
1223                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1224                                / frame.camera.viewport_size[1].max(1.0);
1225                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1226                        let mut s = 1.0_f32;
1227                        let mut iters = 0u32;
1228                        while s < target {
1229                            s *= 10.0;
1230                            iters += 1;
1231                        }
1232                        let ratio = (target / s).clamp(0.0, 1.0);
1233                        let fade = if ratio < 0.5 {
1234                            1.0_f32
1235                        } else {
1236                            let t = (ratio - 0.5) * 2.0;
1237                            1.0 - t * t * (3.0 - 2.0 * t)
1238                        };
1239                        tracing::debug!(
1240                            eye_z = eye.z,
1241                            vertical_depth,
1242                            world_per_pixel,
1243                            target,
1244                            spacing = s,
1245                            lod_iters = iters,
1246                            ratio,
1247                            minor_fade = fade,
1248                            "grid LOD"
1249                        );
1250                        (s, fade)
1251                    };
1252
1253                    let spacing_major = spacing * 10.0;
1254                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1255                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1256                    tracing::debug!(
1257                        spacing_minor = spacing,
1258                        spacing_major,
1259                        snap_x,
1260                        snap_y,
1261                        eye_x = eye.x,
1262                        eye_y = eye.y,
1263                        eye_z = eye.z,
1264                        "grid snap"
1265                    );
1266
1267                    let orient = frame.camera.render_camera.orientation;
1268                    let right = orient * glam::Vec3::X;
1269                    let up = orient * glam::Vec3::Y;
1270                    let back = orient * glam::Vec3::Z;
1271                    let cam_to_world = [
1272                        [right.x, right.y, right.z, 0.0_f32],
1273                        [up.x, up.y, up.z, 0.0_f32],
1274                        [back.x, back.y, back.z, 0.0_f32],
1275                    ];
1276                    let aspect =
1277                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1278                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1279
1280                    let uniform = GridUniform {
1281                        view_proj: view_proj_mat,
1282                        cam_to_world,
1283                        tan_half_fov,
1284                        aspect,
1285                        _pad_ivp: [0.0; 2],
1286                        eye_pos: frame.camera.render_camera.eye_position,
1287                        grid_z: frame.viewport.grid_z,
1288                        spacing_minor: spacing,
1289                        spacing_major,
1290                        snap_origin: [snap_x, snap_y],
1291                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1292                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1293                    };
1294                    // Write to per-viewport slot buffer.
1295                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1296                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1297                    }
1298                    // Also write to shared buffer for legacy callers.
1299                    queue.write_buffer(
1300                        &resources.grid_uniform_buf,
1301                        0,
1302                        bytemuck::cast_slice(&[uniform]),
1303                    );
1304                }
1305            }
1306            // ------------------------------------------------------------------
1307            // Ground plane uniform upload.
1308            // ------------------------------------------------------------------
1309            {
1310                let gp = &viewport_fx.ground_plane;
1311                let mode_u32: u32 = match gp.mode {
1312                    crate::renderer::types::GroundPlaneMode::None => 0,
1313                    crate::renderer::types::GroundPlaneMode::ShadowOnly => 1,
1314                    crate::renderer::types::GroundPlaneMode::Tile => 2,
1315                    crate::renderer::types::GroundPlaneMode::SolidColor => 3,
1316                };
1317                let orient = frame.camera.render_camera.orientation;
1318                let right = orient * glam::Vec3::X;
1319                let up = orient * glam::Vec3::Y;
1320                let back = orient * glam::Vec3::Z;
1321                let aspect = frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1322                let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1323                let vp = frame.camera.render_camera.view_proj().to_cols_array_2d();
1324                let gp_uniform = crate::resources::GroundPlaneUniform {
1325                    view_proj: vp,
1326                    cam_right: [right.x, right.y, right.z, 0.0],
1327                    cam_up: [up.x, up.y, up.z, 0.0],
1328                    cam_back: [back.x, back.y, back.z, 0.0],
1329                    eye_pos: frame.camera.render_camera.eye_position,
1330                    height: gp.height,
1331                    color: gp.color,
1332                    shadow_color: gp.shadow_color,
1333                    light_vp: gp_cascade0_mat,
1334                    tan_half_fov,
1335                    aspect,
1336                    tile_size: gp.tile_size,
1337                    shadow_bias: 0.002,
1338                    mode: mode_u32,
1339                    shadow_opacity: gp.shadow_opacity,
1340                    _pad: [0.0; 2],
1341                };
1342                queue.write_buffer(
1343                    &resources.ground_plane_uniform_buf,
1344                    0,
1345                    bytemuck::cast_slice(&[gp_uniform]),
1346                );
1347            }
1348        } // `resources` mutable borrow dropped here.
1349
1350        // ------------------------------------------------------------------
1351        // Build per-viewport interaction state into local variables.
1352        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1353        // the slot borrow that follows.
1354        // ------------------------------------------------------------------
1355
1356        let vp_idx = frame.camera.viewport_index;
1357
1358        // Outline mask buffers for selected objects (one per selected object).
1359        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1360        if frame.interaction.outline_selected {
1361            let resources = &self.resources;
1362            for item in scene_items {
1363                if !item.visible || !item.selected {
1364                    continue;
1365                }
1366                let uniform = OutlineUniform {
1367                    model: item.model,
1368                    color: [0.0; 4], // unused by mask shader
1369                    pixel_offset: 0.0,
1370                    _pad: [0.0; 3],
1371                };
1372                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1373                    label: Some("outline_mask_uniform_buf"),
1374                    size: std::mem::size_of::<OutlineUniform>() as u64,
1375                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1376                    mapped_at_creation: false,
1377                });
1378                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1379                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1380                    label: Some("outline_mask_object_bg"),
1381                    layout: &resources.outline_bind_group_layout,
1382                    entries: &[wgpu::BindGroupEntry {
1383                        binding: 0,
1384                        resource: buf.as_entire_binding(),
1385                    }],
1386                });
1387                outline_object_buffers.push(OutlineObjectBuffers {
1388                    mesh_id: item.mesh_id,
1389                    two_sided: item.material.is_two_sided(),
1390                    _mask_uniform_buf: buf,
1391                    mask_bind_group: bg,
1392                });
1393            }
1394        }
1395
1396        // X-ray buffers for selected objects.
1397        let mut xray_object_buffers: Vec<(crate::resources::mesh_store::MeshId, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1398        if frame.interaction.xray_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: frame.interaction.xray_color,
1407                    pixel_offset: 0.0,
1408                    _pad: [0.0; 3],
1409                };
1410                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1411                    label: Some("xray_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("xray_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                xray_object_buffers.push((item.mesh_id, buf, bg));
1426            }
1427        }
1428
1429        // Constraint guide lines.
1430        let mut constraint_line_buffers = Vec::new();
1431        for overlay in &frame.interaction.constraint_overlays {
1432            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
1433        }
1434
1435        // Clip plane overlays : generated automatically from clip_objects with a color set.
1436        let mut clip_plane_fill_buffers = Vec::new();
1437        let mut clip_plane_line_buffers = Vec::new();
1438        for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1439            let Some(base_color) = obj.color else {
1440                continue;
1441            };
1442            if let ClipShape::Plane {
1443                normal, distance, ..
1444            } = obj.shape
1445            {
1446                let n = glam::Vec3::from(normal);
1447                // Shader plane equation: dot(p, n) + distance = 0, so the plane
1448                // sits at -n * distance from the origin.
1449                let center = n * (-distance);
1450                let active = obj.active;
1451                let hovered = obj.hovered || active;
1452
1453                let fill_color = if active {
1454                    [
1455                        base_color[0] * 0.5,
1456                        base_color[1] * 0.5,
1457                        base_color[2] * 0.5,
1458                        base_color[3] * 0.5,
1459                    ]
1460                } else if hovered {
1461                    [
1462                        base_color[0] * 0.8,
1463                        base_color[1] * 0.8,
1464                        base_color[2] * 0.8,
1465                        base_color[3] * 0.6,
1466                    ]
1467                } else {
1468                    [
1469                        base_color[0] * 0.5,
1470                        base_color[1] * 0.5,
1471                        base_color[2] * 0.5,
1472                        base_color[3] * 0.3,
1473                    ]
1474                };
1475                let border_color = if active {
1476                    [base_color[0], base_color[1], base_color[2], 0.9]
1477                } else if hovered {
1478                    [base_color[0], base_color[1], base_color[2], 0.8]
1479                } else {
1480                    [
1481                        base_color[0] * 0.9,
1482                        base_color[1] * 0.9,
1483                        base_color[2] * 0.9,
1484                        0.6,
1485                    ]
1486                };
1487
1488                let overlay = crate::interaction::clip_plane::ClipPlaneOverlay {
1489                    center,
1490                    normal: n,
1491                    extent: obj.extent,
1492                    fill_color,
1493                    border_color,
1494                    hovered,
1495                    active,
1496                };
1497                clip_plane_fill_buffers.push(
1498                    self.resources
1499                        .create_clip_plane_fill_overlay(device, &overlay),
1500                );
1501                clip_plane_line_buffers.push(
1502                    self.resources
1503                        .create_clip_plane_line_overlay(device, &overlay),
1504                );
1505            } else {
1506                // Box/Sphere: generate wireframe polyline.
1507                // ensure_polyline_pipeline must be called before upload_polyline; it is a
1508                // no-op if already initialised, so calling it here is always safe.
1509                self.resources.ensure_polyline_pipeline(device);
1510                match obj.shape {
1511                    ClipShape::Box {
1512                        center,
1513                        half_extents,
1514                        orientation,
1515                    } => {
1516                        let polyline =
1517                            clip_box_outline(center, half_extents, orientation, base_color);
1518                        let vp_size = frame.camera.viewport_size;
1519                        let gpu = self
1520                            .resources
1521                            .upload_polyline(device, queue, &polyline, vp_size);
1522                        self.polyline_gpu_data.push(gpu);
1523                    }
1524                    ClipShape::Sphere { center, radius } => {
1525                        let polyline = clip_sphere_outline(center, radius, base_color);
1526                        let vp_size = frame.camera.viewport_size;
1527                        let gpu = self
1528                            .resources
1529                            .upload_polyline(device, queue, &polyline, vp_size);
1530                        self.polyline_gpu_data.push(gpu);
1531                    }
1532                    _ => {}
1533                }
1534            }
1535        }
1536
1537        // Cap geometry for section-view cross-section fill.
1538        let mut cap_buffers = Vec::new();
1539        if viewport_fx.cap_fill_enabled {
1540            for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1541                if let ClipShape::Plane {
1542                    normal,
1543                    distance,
1544                    cap_color,
1545                } = obj.shape
1546                {
1547                    let plane_n = glam::Vec3::from(normal);
1548                    for item in scene_items.iter().filter(|i| i.visible) {
1549                        let Some(mesh) = self
1550                            .resources
1551                            .mesh_store
1552                            .get(item.mesh_id)
1553                        else {
1554                            continue;
1555                        };
1556                        let model = glam::Mat4::from_cols_array_2d(&item.model);
1557                        let world_aabb = mesh.aabb.transformed(&model);
1558                        if !world_aabb.intersects_plane(plane_n, distance) {
1559                            continue;
1560                        }
1561                        let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1562                        else {
1563                            continue;
1564                        };
1565                        if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
1566                            pos, idx, &model, plane_n, distance,
1567                        ) {
1568                            let bc = item.material.base_color;
1569                            let color = cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
1570                            let buf = self.resources.upload_cap_geometry(device, &cap, color);
1571                            cap_buffers.push(buf);
1572                        }
1573                    }
1574                }
1575            }
1576        }
1577
1578        // Axes indicator geometry (built here, written to slot buffer below).
1579        let axes_verts = if frame.viewport.show_axes_indicator
1580            && frame.camera.viewport_size[0] > 0.0
1581            && frame.camera.viewport_size[1] > 0.0
1582        {
1583            let verts = crate::widgets::axes_indicator::build_axes_geometry(
1584                frame.camera.viewport_size[0],
1585                frame.camera.viewport_size[1],
1586                frame.camera.render_camera.orientation,
1587            );
1588            if verts.is_empty() { None } else { Some(verts) }
1589        } else {
1590            None
1591        };
1592
1593        // Gizmo mesh + uniform (built here, written to slot buffers below).
1594        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
1595            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
1596                frame.interaction.gizmo_mode,
1597                frame.interaction.gizmo_hovered,
1598                frame.interaction.gizmo_space_orientation,
1599            );
1600            (verts, indices, model)
1601        });
1602
1603        // ------------------------------------------------------------------
1604        // Assign all interaction state to the per-viewport slot.
1605        // ------------------------------------------------------------------
1606        {
1607            let slot = &mut self.viewport_slots[vp_idx];
1608            slot.outline_object_buffers = outline_object_buffers;
1609            slot.xray_object_buffers = xray_object_buffers;
1610            slot.constraint_line_buffers = constraint_line_buffers;
1611            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
1612            slot.clip_plane_line_buffers = clip_plane_line_buffers;
1613            slot.cap_buffers = cap_buffers;
1614
1615            // Axes: resize buffer if needed, then upload.
1616            if let Some(verts) = axes_verts {
1617                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
1618                if byte_size > slot.axes_vertex_buffer.size() {
1619                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1620                        label: Some("vp_axes_vertex_buf"),
1621                        size: byte_size,
1622                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1623                        mapped_at_creation: false,
1624                    });
1625                }
1626                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
1627                slot.axes_vertex_count = verts.len() as u32;
1628            } else {
1629                slot.axes_vertex_count = 0;
1630            }
1631
1632            // Gizmo: resize buffers if needed, then upload mesh + uniform.
1633            if let Some((verts, indices, model)) = gizmo_update {
1634                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
1635                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
1636                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
1637                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1638                        label: Some("vp_gizmo_vertex_buf"),
1639                        size: vert_bytes.len() as u64,
1640                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1641                        mapped_at_creation: false,
1642                    });
1643                }
1644                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
1645                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1646                        label: Some("vp_gizmo_index_buf"),
1647                        size: idx_bytes.len() as u64,
1648                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1649                        mapped_at_creation: false,
1650                    });
1651                }
1652                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
1653                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
1654                slot.gizmo_index_count = indices.len() as u32;
1655                let uniform = crate::interaction::gizmo::GizmoUniform {
1656                    model: model.to_cols_array_2d(),
1657                };
1658                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
1659            }
1660        }
1661
1662        // ------------------------------------------------------------------
1663        // Outline offscreen pass : screen-space edge detection.
1664        //
1665        // 1. Render selected objects to an R8 mask texture (white on black).
1666        // 2. Run a fullscreen edge-detection pass reading the mask and writing
1667        //    an anti-aliased outline ring to the outline color texture.
1668        //
1669        // The outline color texture is later composited onto the main target
1670        // by the composite pass in paint()/render().
1671        // ------------------------------------------------------------------
1672        if frame.interaction.outline_selected
1673            && !self.viewport_slots[vp_idx]
1674                .outline_object_buffers
1675                .is_empty()
1676        {
1677            let w = frame.camera.viewport_size[0] as u32;
1678            let h = frame.camera.viewport_size[1] as u32;
1679
1680            // Ensure per-viewport HDR state exists (provides outline textures).
1681            self.ensure_viewport_hdr(
1682                device,
1683                queue,
1684                vp_idx,
1685                w.max(1),
1686                h.max(1),
1687                frame.effects.post_process.ssaa_factor.max(1),
1688            );
1689
1690            // Write edge-detection uniform (color, radius, viewport size).
1691            {
1692                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1693                let edge_uniform = OutlineEdgeUniform {
1694                    color: frame.interaction.outline_color,
1695                    radius: frame.interaction.outline_width_px,
1696                    viewport_w: w as f32,
1697                    viewport_h: h as f32,
1698                    _pad: 0.0,
1699                };
1700                queue.write_buffer(
1701                    &slot_hdr.outline_edge_uniform_buf,
1702                    0,
1703                    bytemuck::cast_slice(&[edge_uniform]),
1704                );
1705            }
1706
1707            // Extract raw pointers for slot fields needed inside the render
1708            // passes alongside &self.resources borrows.
1709            let slot_ref = &self.viewport_slots[vp_idx];
1710            let outlines_ptr =
1711                &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
1712            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
1713            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
1714            let mask_view_ptr = &slot_hdr.outline_mask_view as *const wgpu::TextureView;
1715            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
1716            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
1717            let edge_bg_ptr = &slot_hdr.outline_edge_bind_group as *const wgpu::BindGroup;
1718            // SAFETY: slot fields remain valid for the duration of this function;
1719            // no other code modifies these fields here.
1720            let (outlines, camera_bg, mask_view, color_view, depth_view, edge_bg) = unsafe {
1721                (
1722                    &*outlines_ptr,
1723                    &*camera_bg_ptr,
1724                    &*mask_view_ptr,
1725                    &*color_view_ptr,
1726                    &*depth_view_ptr,
1727                    &*edge_bg_ptr,
1728                )
1729            };
1730
1731            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1732                label: Some("outline_offscreen_encoder"),
1733            });
1734
1735            // Pass 1: render selected objects to R8 mask texture.
1736            {
1737                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1738                    label: Some("outline_mask_pass"),
1739                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1740                        view: mask_view,
1741                        resolve_target: None,
1742                        ops: wgpu::Operations {
1743                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1744                            store: wgpu::StoreOp::Store,
1745                        },
1746                        depth_slice: None,
1747                    })],
1748                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1749                        view: depth_view,
1750                        depth_ops: Some(wgpu::Operations {
1751                            load: wgpu::LoadOp::Clear(1.0),
1752                            store: wgpu::StoreOp::Discard,
1753                        }),
1754                        stencil_ops: None,
1755                    }),
1756                    timestamp_writes: None,
1757                    occlusion_query_set: None,
1758                });
1759
1760                pass.set_bind_group(0, camera_bg, &[]);
1761                for outlined in outlines {
1762                    let Some(mesh) = self
1763                        .resources
1764                        .mesh_store
1765                        .get(outlined.mesh_id)
1766                    else {
1767                        continue;
1768                    };
1769                    let pipeline = if outlined.two_sided {
1770                        &self.resources.outline_mask_two_sided_pipeline
1771                    } else {
1772                        &self.resources.outline_mask_pipeline
1773                    };
1774                    pass.set_pipeline(pipeline);
1775                    pass.set_bind_group(1, &outlined.mask_bind_group, &[]);
1776                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1777                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1778                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1779                }
1780            }
1781
1782            // Pass 2: fullscreen edge detection (reads mask, writes color).
1783            {
1784                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1785                    label: Some("outline_edge_pass"),
1786                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1787                        view: color_view,
1788                        resolve_target: None,
1789                        ops: wgpu::Operations {
1790                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1791                            store: wgpu::StoreOp::Store,
1792                        },
1793                        depth_slice: None,
1794                    })],
1795                    depth_stencil_attachment: None,
1796                    timestamp_writes: None,
1797                    occlusion_query_set: None,
1798                });
1799                pass.set_pipeline(&self.resources.outline_edge_pipeline);
1800                pass.set_bind_group(0, edge_bg, &[]);
1801                pass.draw(0..3, 0..1);
1802            }
1803
1804            queue.submit(std::iter::once(encoder.finish()));
1805        }
1806    }
1807
1808    /// Upload per-frame data to GPU buffers and render the shadow pass.
1809    /// Call before `paint()`.
1810    pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, frame: &FrameData) {
1811        let (scene_fx, viewport_fx) = frame.effects.split();
1812        self.prepare_scene_internal(device, queue, frame, &scene_fx);
1813        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
1814    }
1815}
1816
1817// ---------------------------------------------------------------------------
1818// Clip boundary wireframe helpers (used by prepare_viewport_internal)
1819// ---------------------------------------------------------------------------
1820
1821/// Wireframe outline for a clip box (12 edges as 2-point polyline strips).
1822fn clip_box_outline(
1823    center: [f32; 3],
1824    half: [f32; 3],
1825    orientation: [[f32; 3]; 3],
1826    color: [f32; 4],
1827) -> PolylineItem {
1828    let ax = glam::Vec3::from(orientation[0]) * half[0];
1829    let ay = glam::Vec3::from(orientation[1]) * half[1];
1830    let az = glam::Vec3::from(orientation[2]) * half[2];
1831    let c = glam::Vec3::from(center);
1832
1833    let corners = [
1834        c - ax - ay - az,
1835        c + ax - ay - az,
1836        c + ax + ay - az,
1837        c - ax + ay - az,
1838        c - ax - ay + az,
1839        c + ax - ay + az,
1840        c + ax + ay + az,
1841        c - ax + ay + az,
1842    ];
1843    let edges: [(usize, usize); 12] = [
1844        (0, 1),
1845        (1, 2),
1846        (2, 3),
1847        (3, 0), // bottom face
1848        (4, 5),
1849        (5, 6),
1850        (6, 7),
1851        (7, 4), // top face
1852        (0, 4),
1853        (1, 5),
1854        (2, 6),
1855        (3, 7), // verticals
1856    ];
1857
1858    let mut positions = Vec::with_capacity(24);
1859    let mut strip_lengths = Vec::with_capacity(12);
1860    for (a, b) in edges {
1861        positions.push(corners[a].to_array());
1862        positions.push(corners[b].to_array());
1863        strip_lengths.push(2u32);
1864    }
1865
1866    let mut item = PolylineItem::default();
1867    item.positions = positions;
1868    item.strip_lengths = strip_lengths;
1869    item.default_color = color;
1870    item.line_width = 2.0;
1871    item
1872}
1873
1874/// Wireframe outline for a clip sphere (three great circles).
1875fn clip_sphere_outline(center: [f32; 3], radius: f32, color: [f32; 4]) -> PolylineItem {
1876    let c = glam::Vec3::from(center);
1877    let segs = 64usize;
1878    let mut positions = Vec::with_capacity((segs + 1) * 3);
1879    let mut strip_lengths = Vec::with_capacity(3);
1880
1881    for axis in 0..3usize {
1882        let start = positions.len();
1883        for i in 0..=segs {
1884            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
1885            let (s, cs) = t.sin_cos();
1886            let p = c + match axis {
1887                0 => glam::Vec3::new(cs * radius, s * radius, 0.0),
1888                1 => glam::Vec3::new(cs * radius, 0.0, s * radius),
1889                _ => glam::Vec3::new(0.0, cs * radius, s * radius),
1890            };
1891            positions.push(p.to_array());
1892        }
1893        strip_lengths.push((positions.len() - start) as u32);
1894    }
1895
1896    let mut item = PolylineItem::default();
1897    item.positions = positions;
1898    item.strip_lengths = strip_lengths;
1899    item.default_color = color;
1900    item.line_width = 2.0;
1901    item
1902}