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.iter().any(|i| i.two_sided || i.material.is_two_sided());
391        let has_matcap_items = scene_items.iter().any(|i| i.material.matcap_id.is_some());
392        let has_param_vis_items = scene_items.iter().any(|i| i.material.param_vis.is_some());
393        if !self.use_instancing
394            || frame.viewport.wireframe_mode
395            || has_scalar_items
396            || has_two_sided_items
397            || has_matcap_items
398            || has_param_vis_items
399        {
400            for item in scene_items {
401                if resources
402                    .mesh_store
403                    .get(crate::resources::mesh_store::MeshId(item.mesh_index))
404                    .is_none()
405                {
406                    tracing::warn!(
407                        mesh_index = item.mesh_index,
408                        "scene item mesh_index invalid, skipping"
409                    );
410                    continue;
411                };
412                let m = &item.material;
413                // Compute scalar attribute range.
414                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
415                    let range = item
416                        .scalar_range
417                        .or_else(|| {
418                            resources
419                                .mesh_store
420                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
421                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
422                        })
423                        .unwrap_or((0.0, 1.0));
424                    (1u32, range.0, range.1)
425                } else {
426                    (0u32, 0.0, 1.0)
427                };
428                let obj_uniform = ObjectUniform {
429                    model: item.model,
430                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
431                    selected: if item.selected { 1 } else { 0 },
432                    wireframe: if frame.viewport.wireframe_mode { 1 } else { 0 },
433                    ambient: m.ambient,
434                    diffuse: m.diffuse,
435                    specular: m.specular,
436                    shininess: m.shininess,
437                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
438                    use_pbr: if m.use_pbr { 1 } else { 0 },
439                    metallic: m.metallic,
440                    roughness: m.roughness,
441                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
442                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
443                    has_attribute: has_attr,
444                    scalar_min: s_min,
445                    scalar_max: s_max,
446                    _pad_scalar: 0,
447                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
448                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
449                    use_matcap: if m.matcap_id.is_some() { 1 } else { 0 },
450                    matcap_blendable: m.matcap_id.map_or(0, |id| if id.blendable { 1 } else { 0 }),
451                    _pad2: 0,
452                    use_face_color: u32::from(
453                        item.active_attribute.as_ref()
454                            .map_or(false, |a| a.kind == crate::resources::AttributeKind::FaceColor)
455                    ),
456                    uv_vis_mode: m.param_vis.map_or(0, |pv| pv.mode as u32),
457                    uv_vis_scale: m.param_vis.map_or(8.0, |pv| pv.scale),
458                    backface_policy: match m.backface_policy {
459                        crate::scene::material::BackfacePolicy::Cull => 0,
460                        crate::scene::material::BackfacePolicy::Identical => 1,
461                        crate::scene::material::BackfacePolicy::DifferentColor(_) => 2,
462                    },
463                    backface_color: match m.backface_policy {
464                        crate::scene::material::BackfacePolicy::DifferentColor(c) => [c[0], c[1], c[2], 1.0],
465                        _ => [0.0; 4],
466                    },
467                };
468
469                let normal_obj_uniform = ObjectUniform {
470                    model: item.model,
471                    color: [1.0, 1.0, 1.0, 1.0],
472                    selected: 0,
473                    wireframe: 0,
474                    ambient: 0.15,
475                    diffuse: 0.75,
476                    specular: 0.4,
477                    shininess: 32.0,
478                    has_texture: 0,
479                    use_pbr: 0,
480                    metallic: 0.0,
481                    roughness: 0.5,
482                    has_normal_map: 0,
483                    has_ao_map: 0,
484                    has_attribute: 0,
485                    scalar_min: 0.0,
486                    scalar_max: 1.0,
487                    _pad_scalar: 0,
488                    nan_color: [0.0; 4],
489                    use_nan_color: 0,
490                    use_matcap: 0,
491                    matcap_blendable: 0,
492                    _pad2: 0,
493                    use_face_color: 0,
494                    uv_vis_mode: 0,
495                    uv_vis_scale: 8.0,
496                    backface_policy: 0,
497                    backface_color: [0.0; 4],
498                };
499
500                // Write uniform data — use get() to read buffer references, then drop.
501                {
502                    let mesh = resources
503                        .mesh_store
504                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
505                        .unwrap();
506                    queue.write_buffer(
507                        &mesh.object_uniform_buf,
508                        0,
509                        bytemuck::cast_slice(&[obj_uniform]),
510                    );
511                    queue.write_buffer(
512                        &mesh.normal_uniform_buf,
513                        0,
514                        bytemuck::cast_slice(&[normal_obj_uniform]),
515                    );
516                } // mesh borrow dropped here
517
518                // Rebuild the object bind group if material/attribute/LUT/matcap changed.
519                resources.update_mesh_texture_bind_group(
520                    device,
521                    item.mesh_index,
522                    item.material.texture_id,
523                    item.material.normal_map_id,
524                    item.material.ao_map_id,
525                    item.colormap_id,
526                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
527                    item.material.matcap_id,
528                );
529            }
530        }
531
532        if self.use_instancing {
533            resources.ensure_instanced_pipelines(device);
534
535            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
536            // Phase 2: wireframe_mode removed from cache key — wireframe rendering
537            // uses the per-object wireframe_pipeline, not the instanced path, so
538            // instance data is now viewport-agnostic.
539            let cache_valid = frame.scene.generation == self.last_scene_generation
540                && frame.interaction.selection_generation == self.last_selection_generation
541                && scene_items.len() == self.last_scene_items_count;
542
543            if !cache_valid {
544                // Cache miss — rebuild batches and upload instance data.
545                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
546                    .iter()
547                    .filter(|item| {
548                        item.visible
549                            && item.active_attribute.is_none()
550                            && !item.two_sided
551                            && !item.material.is_two_sided()
552                            && item.material.matcap_id.is_none()
553                            && item.material.param_vis.is_none()
554                            && resources
555                                .mesh_store
556                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
557                                .is_some()
558                    })
559                    .collect();
560
561                sorted_items.sort_unstable_by_key(|item| {
562                    (
563                        item.mesh_index,
564                        item.material.texture_id,
565                        item.material.normal_map_id,
566                        item.material.ao_map_id,
567                    )
568                });
569
570                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
571                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
572
573                if !sorted_items.is_empty() {
574                    let mut batch_start = 0usize;
575                    for i in 1..=sorted_items.len() {
576                        let at_end = i == sorted_items.len();
577                        let key_changed = !at_end && {
578                            let a = sorted_items[batch_start];
579                            let b = sorted_items[i];
580                            a.mesh_index != b.mesh_index
581                                || a.material.texture_id != b.material.texture_id
582                                || a.material.normal_map_id != b.material.normal_map_id
583                                || a.material.ao_map_id != b.material.ao_map_id
584                        };
585
586                        if at_end || key_changed {
587                            let batch_items = &sorted_items[batch_start..i];
588                            let rep = batch_items[0];
589                            let instance_offset = all_instances.len() as u32;
590                            let is_transparent = rep.material.opacity < 1.0;
591
592                            for item in batch_items {
593                                let m = &item.material;
594                                all_instances.push(InstanceData {
595                                    model: item.model,
596                                    color: [
597                                        m.base_color[0],
598                                        m.base_color[1],
599                                        m.base_color[2],
600                                        m.opacity,
601                                    ],
602                                    selected: if item.selected { 1 } else { 0 },
603                                    wireframe: 0, // Phase 2: always 0 — wireframe uses per-object pipeline
604                                    ambient: m.ambient,
605                                    diffuse: m.diffuse,
606                                    specular: m.specular,
607                                    shininess: m.shininess,
608                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
609                                    use_pbr: if m.use_pbr { 1 } else { 0 },
610                                    metallic: m.metallic,
611                                    roughness: m.roughness,
612                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
613                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
614                                });
615                            }
616
617                            instanced_batches.push(InstancedBatch {
618                                mesh_index: rep.mesh_index,
619                                texture_id: rep.material.texture_id,
620                                normal_map_id: rep.material.normal_map_id,
621                                ao_map_id: rep.material.ao_map_id,
622                                instance_offset,
623                                instance_count: batch_items.len() as u32,
624                                is_transparent,
625                            });
626
627                            batch_start = i;
628                        }
629                    }
630                }
631
632                self.cached_instance_data = all_instances;
633                self.cached_instanced_batches = instanced_batches;
634
635                resources.upload_instance_data(device, queue, &self.cached_instance_data);
636
637                self.instanced_batches = self.cached_instanced_batches.clone();
638
639                self.last_scene_generation = frame.scene.generation;
640                self.last_selection_generation = frame.interaction.selection_generation;
641                self.last_scene_items_count = scene_items.len();
642
643                for batch in &self.instanced_batches {
644                    resources.get_instance_bind_group(
645                        device,
646                        batch.texture_id,
647                        batch.normal_map_id,
648                        batch.ao_map_id,
649                    );
650                }
651            } else {
652                for batch in &self.instanced_batches {
653                    resources.get_instance_bind_group(
654                        device,
655                        batch.texture_id,
656                        batch.normal_map_id,
657                        batch.ao_map_id,
658                    );
659                }
660            }
661        }
662
663        // ------------------------------------------------------------------
664        // SciVis Phase B — point cloud and glyph GPU data upload.
665        // ------------------------------------------------------------------
666        self.point_cloud_gpu_data.clear();
667        if !frame.scene.point_clouds.is_empty() {
668            resources.ensure_point_cloud_pipeline(device);
669            for item in &frame.scene.point_clouds {
670                if item.positions.is_empty() {
671                    continue;
672                }
673                let gpu_data = resources.upload_point_cloud(device, queue, item);
674                self.point_cloud_gpu_data.push(gpu_data);
675            }
676        }
677
678        self.glyph_gpu_data.clear();
679        if !frame.scene.glyphs.is_empty() {
680            resources.ensure_glyph_pipeline(device);
681            for item in &frame.scene.glyphs {
682                if item.positions.is_empty() || item.vectors.is_empty() {
683                    continue;
684                }
685                let gpu_data = resources.upload_glyph_set(device, queue, item);
686                self.glyph_gpu_data.push(gpu_data);
687            }
688        }
689
690        // ------------------------------------------------------------------
691        // SciVis Phase M8 — polyline GPU data upload.
692        // ------------------------------------------------------------------
693        self.polyline_gpu_data.clear();
694        let vp_size = frame.camera.viewport_size;
695        if !frame.scene.polylines.is_empty() {
696            resources.ensure_polyline_pipeline(device);
697            for item in &frame.scene.polylines {
698                if item.positions.is_empty() {
699                    continue;
700                }
701                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
702                self.polyline_gpu_data.push(gpu_data);
703            }
704        }
705
706        // ------------------------------------------------------------------
707        // SciVis Phase L — isoline extraction and upload via polyline pipeline.
708        // ------------------------------------------------------------------
709        if !frame.scene.isolines.is_empty() {
710            resources.ensure_polyline_pipeline(device);
711            for item in &frame.scene.isolines {
712                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
713                    continue;
714                }
715                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
716                if positions.is_empty() {
717                    continue;
718                }
719                let polyline = PolylineItem {
720                    positions,
721                    scalars: Vec::new(),
722                    strip_lengths,
723                    scalar_range: None,
724                    colormap_id: None,
725                    default_color: item.color,
726                    line_width: item.line_width,
727                    id: 0,
728                };
729                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
730                self.polyline_gpu_data.push(gpu_data);
731            }
732        }
733
734        // ------------------------------------------------------------------
735        // Phase 10A — camera frustum wireframes (converted to polylines).
736        // ------------------------------------------------------------------
737        if !frame.scene.camera_frustums.is_empty() {
738            resources.ensure_polyline_pipeline(device);
739            for item in &frame.scene.camera_frustums {
740                let polyline = item.to_polyline();
741                if !polyline.positions.is_empty() {
742                    let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
743                    self.polyline_gpu_data.push(gpu_data);
744                }
745            }
746        }
747
748        // ------------------------------------------------------------------
749        // Phase 10B — screen-space image overlays.
750        // ------------------------------------------------------------------
751        self.screen_image_gpu_data.clear();
752        if !frame.scene.screen_images.is_empty() {
753            resources.ensure_screen_image_pipeline(device);
754            let vp_w = vp_size[0];
755            let vp_h = vp_size[1];
756            for item in &frame.scene.screen_images {
757                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
758                    continue;
759                }
760                let gpu = resources.upload_screen_image(device, queue, item, vp_w, vp_h);
761                self.screen_image_gpu_data.push(gpu);
762            }
763        }
764
765        // ------------------------------------------------------------------
766        // SciVis Phase M — streamtube GPU data upload.
767        // ------------------------------------------------------------------
768        self.streamtube_gpu_data.clear();
769        if !frame.scene.streamtube_items.is_empty() {
770            resources.ensure_streamtube_pipeline(device);
771            for item in &frame.scene.streamtube_items {
772                if item.positions.is_empty() || item.strip_lengths.is_empty() {
773                    continue;
774                }
775                let gpu_data = resources.upload_streamtube(device, queue, item);
776                if gpu_data.index_count > 0 {
777                    self.streamtube_gpu_data.push(gpu_data);
778                }
779            }
780        }
781
782        // ------------------------------------------------------------------
783        // SciVis Phase D — volume GPU data upload.
784        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
785        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
786        // ------------------------------------------------------------------
787        self.volume_gpu_data.clear();
788        if !frame.scene.volumes.is_empty() {
789            resources.ensure_volume_pipeline(device);
790            // Extract ClipPlane structs from clip_objects for volume cap fill support.
791            let clip_planes_for_vol: Vec<crate::renderer::types::ClipPlane> = frame
792                .effects
793                .clip_objects
794                .iter()
795                .filter(|o| o.enabled)
796                .filter_map(|o| {
797                    if let ClipShape::Plane { normal, distance, cap_color } = o.shape {
798                        Some(crate::renderer::types::ClipPlane {
799                            normal,
800                            distance,
801                            enabled: true,
802                            cap_color,
803                        })
804                    } else {
805                        None
806                    }
807                })
808                .collect();
809            for item in &frame.scene.volumes {
810                let gpu =
811                    resources.upload_volume_frame(device, queue, item, &clip_planes_for_vol);
812                self.volume_gpu_data.push(gpu);
813            }
814        }
815
816        // -- Frame stats --
817        {
818            let total = scene_items.len() as u32;
819            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
820            let mut draw_calls = 0u32;
821            let mut triangles = 0u64;
822            let instanced_batch_count = if self.use_instancing {
823                self.instanced_batches.len() as u32
824            } else {
825                0
826            };
827
828            if self.use_instancing {
829                for batch in &self.instanced_batches {
830                    if let Some(mesh) = resources
831                        .mesh_store
832                        .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
833                    {
834                        draw_calls += 1;
835                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
836                    }
837                }
838            } else {
839                for item in scene_items {
840                    if !item.visible {
841                        continue;
842                    }
843                    if let Some(mesh) = resources
844                        .mesh_store
845                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
846                    {
847                        draw_calls += 1;
848                        triangles += (mesh.index_count / 3) as u64;
849                    }
850                }
851            }
852
853            self.last_stats = crate::renderer::stats::FrameStats {
854                total_objects: total,
855                visible_objects: visible,
856                culled_objects: total.saturating_sub(visible),
857                draw_calls,
858                instanced_batches: instanced_batch_count,
859                triangles_submitted: triangles,
860                shadow_draw_calls: 0, // Updated below in shadow pass.
861            };
862        }
863
864        // ------------------------------------------------------------------
865        // Shadow depth pass — CSM: render each cascade into its atlas tile.
866        // ------------------------------------------------------------------
867        if lighting.shadows_enabled && !scene_items.is_empty() {
868            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
869                label: Some("shadow_pass_encoder"),
870            });
871            {
872                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
873                    label: Some("shadow_pass"),
874                    color_attachments: &[],
875                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
876                        view: &resources.shadow_map_view,
877                        depth_ops: Some(wgpu::Operations {
878                            load: wgpu::LoadOp::Clear(1.0),
879                            store: wgpu::StoreOp::Store,
880                        }),
881                        stencil_ops: None,
882                    }),
883                    timestamp_writes: None,
884                    occlusion_query_set: None,
885                });
886
887                let mut shadow_draws = 0u32;
888                let tile_px = tile_size as f32;
889
890                if self.use_instancing {
891                    if let (Some(pipeline), Some(instance_bg)) = (
892                        &resources.shadow_instanced_pipeline,
893                        self.instanced_batches.first().and_then(|b| {
894                            resources.instance_bind_groups.get(&(
895                                b.texture_id.unwrap_or(u64::MAX),
896                                b.normal_map_id.unwrap_or(u64::MAX),
897                                b.ao_map_id.unwrap_or(u64::MAX),
898                            ))
899                        }),
900                    ) {
901                        for cascade in 0..effective_cascade_count {
902                            let tile_col = (cascade % 2) as f32;
903                            let tile_row = (cascade / 2) as f32;
904                            shadow_pass.set_viewport(
905                                tile_col * tile_px,
906                                tile_row * tile_px,
907                                tile_px,
908                                tile_px,
909                                0.0,
910                                1.0,
911                            );
912                            shadow_pass.set_scissor_rect(
913                                (tile_col * tile_px) as u32,
914                                (tile_row * tile_px) as u32,
915                                tile_size,
916                                tile_size,
917                            );
918
919                            shadow_pass.set_pipeline(pipeline);
920
921                            queue.write_buffer(
922                                resources.shadow_instanced_cascade_bufs[cascade]
923                                    .as_ref()
924                                    .expect("shadow_instanced_cascade_bufs not allocated"),
925                                0,
926                                bytemuck::cast_slice(
927                                    &cascade_view_projs[cascade].to_cols_array_2d(),
928                                ),
929                            );
930
931                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
932                                .as_ref()
933                                .expect("shadow_instanced_cascade_bgs not allocated");
934                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
935                            shadow_pass.set_bind_group(1, instance_bg, &[]);
936
937                            for batch in &self.instanced_batches {
938                                if batch.is_transparent {
939                                    continue;
940                                }
941                                let Some(mesh) = resources
942                                    .mesh_store
943                                    .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
944                                else {
945                                    continue;
946                                };
947                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
948                                shadow_pass.set_index_buffer(
949                                    mesh.index_buffer.slice(..),
950                                    wgpu::IndexFormat::Uint32,
951                                );
952                                shadow_pass.draw_indexed(
953                                    0..mesh.index_count,
954                                    0,
955                                    batch.instance_offset
956                                        ..batch.instance_offset + batch.instance_count,
957                                );
958                                shadow_draws += 1;
959                            }
960                        }
961                    }
962                } else {
963                    for cascade in 0..effective_cascade_count {
964                        let tile_col = (cascade % 2) as f32;
965                        let tile_row = (cascade / 2) as f32;
966                        shadow_pass.set_viewport(
967                            tile_col * tile_px,
968                            tile_row * tile_px,
969                            tile_px,
970                            tile_px,
971                            0.0,
972                            1.0,
973                        );
974                        shadow_pass.set_scissor_rect(
975                            (tile_col * tile_px) as u32,
976                            (tile_row * tile_px) as u32,
977                            tile_size,
978                            tile_size,
979                        );
980
981                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
982                        shadow_pass.set_bind_group(
983                            0,
984                            &resources.shadow_bind_group,
985                            &[cascade as u32 * 256],
986                        );
987
988                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
989                            &cascade_view_projs[cascade],
990                        );
991
992                        for item in scene_items.iter() {
993                            if !item.visible {
994                                continue;
995                            }
996                            if item.material.opacity < 1.0 {
997                                continue;
998                            }
999                            let Some(mesh) = resources
1000                                .mesh_store
1001                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1002                            else {
1003                                continue;
1004                            };
1005
1006                            let world_aabb = mesh
1007                                .aabb
1008                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1009                            if cascade_frustum.cull_aabb(&world_aabb) {
1010                                continue;
1011                            }
1012
1013                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1014                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1015                            shadow_pass.set_index_buffer(
1016                                mesh.index_buffer.slice(..),
1017                                wgpu::IndexFormat::Uint32,
1018                            );
1019                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1020                            shadow_draws += 1;
1021                        }
1022                    }
1023                }
1024                drop(shadow_pass);
1025                self.last_stats.shadow_draw_calls = shadow_draws;
1026            }
1027            queue.submit(std::iter::once(encoder.finish()));
1028        }
1029    }
1030
1031    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
1032    ///
1033    /// Call once per viewport per frame, after `prepare_scene_internal`.
1034    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
1035    pub(super) fn prepare_viewport_internal(
1036        &mut self,
1037        device: &wgpu::Device,
1038        queue: &wgpu::Queue,
1039        frame: &FrameData,
1040        viewport_fx: &ViewportEffects<'_>,
1041    ) {
1042        // Ensure a per-viewport camera slot exists for this viewport index.
1043        // Must happen before the `resources` borrow below.
1044        self.ensure_viewport_slot(device, frame.camera.viewport_index);
1045
1046        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1047            SurfaceSubmission::Flat(items) => items,
1048        };
1049
1050        // Capture before the resources mutable borrow so it's accessible inside the block.
1051        let gp_cascade0_mat = self.last_cascade0_shadow_mat.to_cols_array_2d();
1052
1053        {
1054            let resources = &mut self.resources;
1055
1056            // Upload clip planes + clip volume uniforms from clip_objects.
1057            {
1058                let mut planes = [[0.0f32; 4]; 6];
1059                let mut count = 0u32;
1060                let mut clip_vol_uniform: ClipVolumeUniform = bytemuck::Zeroable::zeroed(); // volume_type=0
1061
1062                for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1063                    match obj.shape {
1064                        ClipShape::Plane { normal, distance, .. } if count < 6 => {
1065                            planes[count as usize] = [normal[0], normal[1], normal[2], distance];
1066                            count += 1;
1067                        }
1068                        ClipShape::Box { center, half_extents, orientation }
1069                            if clip_vol_uniform.volume_type == 0 =>
1070                        {
1071                            clip_vol_uniform.volume_type = 2;
1072                            clip_vol_uniform.box_center = center;
1073                            clip_vol_uniform.box_half_extents = half_extents;
1074                            clip_vol_uniform.box_col0 = orientation[0];
1075                            clip_vol_uniform.box_col1 = orientation[1];
1076                            clip_vol_uniform.box_col2 = orientation[2];
1077                        }
1078                        ClipShape::Sphere { center, radius }
1079                            if clip_vol_uniform.volume_type == 0 =>
1080                        {
1081                            clip_vol_uniform.volume_type = 3;
1082                            clip_vol_uniform.sphere_center = center;
1083                            clip_vol_uniform.sphere_radius = radius;
1084                        }
1085                        _ => {}
1086                    }
1087                }
1088
1089                let clip_uniform = ClipPlanesUniform {
1090                    planes,
1091                    count,
1092                    _pad0: 0,
1093                    viewport_width: frame.camera.viewport_size[0].max(1.0),
1094                    viewport_height: frame.camera.viewport_size[1].max(1.0),
1095                };
1096                // Write to per-viewport slot buffer.
1097                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1098                    queue.write_buffer(
1099                        &slot.clip_planes_buf,
1100                        0,
1101                        bytemuck::cast_slice(&[clip_uniform]),
1102                    );
1103                    queue.write_buffer(
1104                        &slot.clip_volume_buf,
1105                        0,
1106                        bytemuck::cast_slice(&[clip_vol_uniform]),
1107                    );
1108                }
1109                // Also write to shared buffers for legacy single-viewport callers.
1110                queue.write_buffer(
1111                    &resources.clip_planes_uniform_buf,
1112                    0,
1113                    bytemuck::cast_slice(&[clip_uniform]),
1114                );
1115                queue.write_buffer(
1116                    &resources.clip_volume_uniform_buf,
1117                    0,
1118                    bytemuck::cast_slice(&[clip_vol_uniform]),
1119                );
1120            }
1121
1122            // Upload camera uniform to per-viewport slot buffer.
1123            let camera_uniform = frame.camera.render_camera.camera_uniform();
1124            // Write to shared buffer for legacy single-viewport callers.
1125            queue.write_buffer(
1126                &resources.camera_uniform_buf,
1127                0,
1128                bytemuck::cast_slice(&[camera_uniform]),
1129            );
1130            // Write to the per-viewport slot buffer.
1131            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1132                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1133            }
1134
1135            // Upload grid uniform (full-screen analytical shader — no vertex buffers needed).
1136            if frame.viewport.show_grid {
1137                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1138                if !eye.is_finite() {
1139                    tracing::warn!(
1140                        eye_x = eye.x,
1141                        eye_y = eye.y,
1142                        eye_z = eye.z,
1143                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1144                    );
1145                } else {
1146                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1147
1148                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1149                        (frame.viewport.grid_cell_size, 1.0_f32)
1150                    } else {
1151                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1152                        let world_per_pixel =
1153                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1154                                / frame.camera.viewport_size[1].max(1.0);
1155                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1156                        let mut s = 1.0_f32;
1157                        let mut iters = 0u32;
1158                        while s < target {
1159                            s *= 10.0;
1160                            iters += 1;
1161                        }
1162                        let ratio = (target / s).clamp(0.0, 1.0);
1163                        let fade = if ratio < 0.5 {
1164                            1.0_f32
1165                        } else {
1166                            let t = (ratio - 0.5) * 2.0;
1167                            1.0 - t * t * (3.0 - 2.0 * t)
1168                        };
1169                        tracing::debug!(
1170                            eye_z = eye.z,
1171                            vertical_depth,
1172                            world_per_pixel,
1173                            target,
1174                            spacing = s,
1175                            lod_iters = iters,
1176                            ratio,
1177                            minor_fade = fade,
1178                            "grid LOD"
1179                        );
1180                        (s, fade)
1181                    };
1182
1183                    let spacing_major = spacing * 10.0;
1184                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1185                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1186                    tracing::debug!(
1187                        spacing_minor = spacing,
1188                        spacing_major,
1189                        snap_x,
1190                        snap_y,
1191                        eye_x = eye.x,
1192                        eye_y = eye.y,
1193                        eye_z = eye.z,
1194                        "grid snap"
1195                    );
1196
1197                    let orient = frame.camera.render_camera.orientation;
1198                    let right = orient * glam::Vec3::X;
1199                    let up = orient * glam::Vec3::Y;
1200                    let back = orient * glam::Vec3::Z;
1201                    let cam_to_world = [
1202                        [right.x, right.y, right.z, 0.0_f32],
1203                        [up.x, up.y, up.z, 0.0_f32],
1204                        [back.x, back.y, back.z, 0.0_f32],
1205                    ];
1206                    let aspect =
1207                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1208                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1209
1210                    let uniform = GridUniform {
1211                        view_proj: view_proj_mat,
1212                        cam_to_world,
1213                        tan_half_fov,
1214                        aspect,
1215                        _pad_ivp: [0.0; 2],
1216                        eye_pos: frame.camera.render_camera.eye_position,
1217                        grid_z: frame.viewport.grid_z,
1218                        spacing_minor: spacing,
1219                        spacing_major,
1220                        snap_origin: [snap_x, snap_y],
1221                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1222                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1223                    };
1224                    // Write to per-viewport slot buffer.
1225                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1226                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1227                    }
1228                    // Also write to shared buffer for legacy callers.
1229                    queue.write_buffer(
1230                        &resources.grid_uniform_buf,
1231                        0,
1232                        bytemuck::cast_slice(&[uniform]),
1233                    );
1234                }
1235            }
1236            // ------------------------------------------------------------------
1237            // Ground plane uniform upload.
1238            // ------------------------------------------------------------------
1239            {
1240                let gp = &viewport_fx.ground_plane;
1241                let mode_u32: u32 = match gp.mode {
1242                    crate::renderer::types::GroundPlaneMode::None => 0,
1243                    crate::renderer::types::GroundPlaneMode::ShadowOnly => 1,
1244                    crate::renderer::types::GroundPlaneMode::Tile => 2,
1245                    crate::renderer::types::GroundPlaneMode::SolidColor => 3,
1246                };
1247                let orient = frame.camera.render_camera.orientation;
1248                let right = orient * glam::Vec3::X;
1249                let up = orient * glam::Vec3::Y;
1250                let back = orient * glam::Vec3::Z;
1251                let aspect = frame.camera.viewport_size[0]
1252                    / frame.camera.viewport_size[1].max(1.0);
1253                let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1254                let vp = frame.camera.render_camera.view_proj().to_cols_array_2d();
1255                let gp_uniform = crate::resources::GroundPlaneUniform {
1256                    view_proj: vp,
1257                    cam_right: [right.x, right.y, right.z, 0.0],
1258                    cam_up: [up.x, up.y, up.z, 0.0],
1259                    cam_back: [back.x, back.y, back.z, 0.0],
1260                    eye_pos: frame.camera.render_camera.eye_position,
1261                    height: gp.height,
1262                    color: gp.color,
1263                    shadow_color: gp.shadow_color,
1264                    light_vp: gp_cascade0_mat,
1265                    tan_half_fov,
1266                    aspect,
1267                    tile_size: gp.tile_size,
1268                    shadow_bias: 0.002,
1269                    mode: mode_u32,
1270                    shadow_opacity: gp.shadow_opacity,
1271                    _pad: [0.0; 2],
1272                };
1273                queue.write_buffer(
1274                    &resources.ground_plane_uniform_buf,
1275                    0,
1276                    bytemuck::cast_slice(&[gp_uniform]),
1277                );
1278            }
1279
1280        } // `resources` mutable borrow dropped here.
1281
1282        // ------------------------------------------------------------------
1283        // Build per-viewport interaction state into local variables.
1284        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1285        // the slot borrow that follows.
1286        // ------------------------------------------------------------------
1287
1288        let vp_idx = frame.camera.viewport_index;
1289
1290        // Outline buffers for selected objects.
1291        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1292        if frame.interaction.outline_selected {
1293            let resources = &self.resources;
1294            for item in scene_items {
1295                if !item.visible || !item.selected {
1296                    continue;
1297                }
1298                let m = &item.material;
1299                let stencil_uniform = ObjectUniform {
1300                    model: item.model,
1301                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
1302                    selected: 1,
1303                    wireframe: 0,
1304                    ambient: m.ambient,
1305                    diffuse: m.diffuse,
1306                    specular: m.specular,
1307                    shininess: m.shininess,
1308                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
1309                    use_pbr: if m.use_pbr { 1 } else { 0 },
1310                    metallic: m.metallic,
1311                    roughness: m.roughness,
1312                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
1313                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
1314                    has_attribute: 0,
1315                    scalar_min: 0.0,
1316                    scalar_max: 1.0,
1317                    _pad_scalar: 0,
1318                    nan_color: [0.0; 4],
1319                    use_nan_color: 0,
1320                    use_matcap: 0, matcap_blendable: 0, _pad2: 0,
1321                    use_face_color: 0, uv_vis_mode: 0, uv_vis_scale: 8.0,
1322                    backface_policy: 0, backface_color: [0.0; 4],
1323                };
1324                let stencil_buf = device.create_buffer(&wgpu::BufferDescriptor {
1325                    label: Some("outline_stencil_object_uniform_buf"),
1326                    size: std::mem::size_of::<ObjectUniform>() as u64,
1327                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1328                    mapped_at_creation: false,
1329                });
1330                queue.write_buffer(&stencil_buf, 0, bytemuck::cast_slice(&[stencil_uniform]));
1331
1332                let albedo_view = match m.texture_id {
1333                    Some(id) if (id as usize) < resources.textures.len() => {
1334                        &resources.textures[id as usize].view
1335                    }
1336                    _ => &resources.fallback_texture.view,
1337                };
1338                let normal_view = match m.normal_map_id {
1339                    Some(id) if (id as usize) < resources.textures.len() => {
1340                        &resources.textures[id as usize].view
1341                    }
1342                    _ => &resources.fallback_normal_map_view,
1343                };
1344                let ao_view = match m.ao_map_id {
1345                    Some(id) if (id as usize) < resources.textures.len() => {
1346                        &resources.textures[id as usize].view
1347                    }
1348                    _ => &resources.fallback_ao_map_view,
1349                };
1350                let stencil_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1351                    label: Some("outline_stencil_object_bg"),
1352                    layout: &resources.object_bind_group_layout,
1353                    entries: &[
1354                        wgpu::BindGroupEntry {
1355                            binding: 0,
1356                            resource: stencil_buf.as_entire_binding(),
1357                        },
1358                        wgpu::BindGroupEntry {
1359                            binding: 1,
1360                            resource: wgpu::BindingResource::TextureView(albedo_view),
1361                        },
1362                        wgpu::BindGroupEntry {
1363                            binding: 2,
1364                            resource: wgpu::BindingResource::Sampler(&resources.material_sampler),
1365                        },
1366                        wgpu::BindGroupEntry {
1367                            binding: 3,
1368                            resource: wgpu::BindingResource::TextureView(normal_view),
1369                        },
1370                        wgpu::BindGroupEntry {
1371                            binding: 4,
1372                            resource: wgpu::BindingResource::TextureView(ao_view),
1373                        },
1374                        wgpu::BindGroupEntry {
1375                            binding: 5,
1376                            resource: wgpu::BindingResource::TextureView(
1377                                &resources.fallback_lut_view,
1378                            ),
1379                        },
1380                        wgpu::BindGroupEntry {
1381                            binding: 6,
1382                            resource: resources.fallback_scalar_buf.as_entire_binding(),
1383                        },
1384                        wgpu::BindGroupEntry {
1385                            binding: 7,
1386                            resource: wgpu::BindingResource::TextureView(
1387                                &resources.fallback_texture.view,
1388                            ),
1389                        },
1390                        wgpu::BindGroupEntry {
1391                            binding: 8,
1392                            resource: resources.fallback_face_color_buf.as_entire_binding(),
1393                        },
1394                    ],
1395                });
1396
1397                let uniform = OutlineUniform {
1398                    model: item.model,
1399                    color: frame.interaction.outline_color,
1400                    pixel_offset: frame.interaction.outline_width_px,
1401                    _pad: [0.0; 3],
1402                };
1403                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1404                    label: Some("outline_uniform_buf"),
1405                    size: std::mem::size_of::<OutlineUniform>() as u64,
1406                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1407                    mapped_at_creation: false,
1408                });
1409                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1410                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1411                    label: Some("outline_object_bg"),
1412                    layout: &resources.outline_bind_group_layout,
1413                    entries: &[wgpu::BindGroupEntry {
1414                        binding: 0,
1415                        resource: buf.as_entire_binding(),
1416                    }],
1417                });
1418                outline_object_buffers.push(OutlineObjectBuffers {
1419                    mesh_index: item.mesh_index,
1420                    two_sided: item.two_sided || item.material.is_two_sided(),
1421                    _stencil_uniform_buf: stencil_buf,
1422                    stencil_bind_group: stencil_bg,
1423                    _outline_uniform_buf: buf,
1424                    outline_bind_group: bg,
1425                });
1426            }
1427        }
1428
1429        // X-ray buffers for selected objects.
1430        let mut xray_object_buffers: Vec<(usize, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1431        if frame.interaction.xray_selected {
1432            let resources = &self.resources;
1433            for item in scene_items {
1434                if !item.visible || !item.selected {
1435                    continue;
1436                }
1437                let uniform = OutlineUniform {
1438                    model: item.model,
1439                    color: frame.interaction.xray_color,
1440                    pixel_offset: 0.0,
1441                    _pad: [0.0; 3],
1442                };
1443                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1444                    label: Some("xray_uniform_buf"),
1445                    size: std::mem::size_of::<OutlineUniform>() as u64,
1446                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1447                    mapped_at_creation: false,
1448                });
1449                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1450                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1451                    label: Some("xray_object_bg"),
1452                    layout: &resources.outline_bind_group_layout,
1453                    entries: &[wgpu::BindGroupEntry {
1454                        binding: 0,
1455                        resource: buf.as_entire_binding(),
1456                    }],
1457                });
1458                xray_object_buffers.push((item.mesh_index, buf, bg));
1459            }
1460        }
1461
1462        // Constraint guide lines.
1463        let mut constraint_line_buffers = Vec::new();
1464        for overlay in &frame.interaction.constraint_overlays {
1465            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
1466        }
1467
1468        // Clip plane overlays — generated automatically from clip_objects with a color set.
1469        let mut clip_plane_fill_buffers = Vec::new();
1470        let mut clip_plane_line_buffers = Vec::new();
1471        for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1472            let Some(base_color) = obj.color else { continue };
1473            if let ClipShape::Plane { normal, distance, .. } = obj.shape {
1474                let n = glam::Vec3::from(normal);
1475                // Shader plane equation: dot(p, n) + distance = 0, so the plane
1476                // sits at -n * distance from the origin.
1477                let center = n * (-distance);
1478                let active = obj.active;
1479                let hovered = obj.hovered || active;
1480
1481                let fill_color = if active {
1482                    [base_color[0] * 0.5, base_color[1] * 0.5, base_color[2] * 0.5, base_color[3] * 0.5]
1483                } else if hovered {
1484                    [base_color[0] * 0.8, base_color[1] * 0.8, base_color[2] * 0.8, base_color[3] * 0.6]
1485                } else {
1486                    [base_color[0] * 0.5, base_color[1] * 0.5, base_color[2] * 0.5, base_color[3] * 0.3]
1487                };
1488                let border_color = if active {
1489                    [base_color[0], base_color[1], base_color[2], 0.9]
1490                } else if hovered {
1491                    [base_color[0], base_color[1], base_color[2], 0.8]
1492                } else {
1493                    [base_color[0] * 0.9, base_color[1] * 0.9, base_color[2] * 0.9, 0.6]
1494                };
1495
1496                let overlay = crate::interaction::clip_plane::ClipPlaneOverlay {
1497                    center,
1498                    normal: n,
1499                    extent: obj.extent,
1500                    fill_color,
1501                    border_color,
1502                    hovered,
1503                    active,
1504                };
1505                clip_plane_fill_buffers.push(
1506                    self.resources.create_clip_plane_fill_overlay(device, &overlay),
1507                );
1508                clip_plane_line_buffers.push(
1509                    self.resources.create_clip_plane_line_overlay(device, &overlay),
1510                );
1511            } else {
1512                // Box/Sphere: generate wireframe polyline.
1513                // ensure_polyline_pipeline must be called before upload_polyline; it is a
1514                // no-op if already initialised, so calling it here is always safe.
1515                self.resources.ensure_polyline_pipeline(device);
1516                match obj.shape {
1517                    ClipShape::Box { center, half_extents, orientation } => {
1518                        let polyline = clip_box_outline(center, half_extents, orientation, base_color);
1519                        let vp_size = frame.camera.viewport_size;
1520                        let gpu = self.resources.upload_polyline(device, queue, &polyline, vp_size);
1521                        self.polyline_gpu_data.push(gpu);
1522                    }
1523                    ClipShape::Sphere { center, radius } => {
1524                        let polyline = clip_sphere_outline(center, radius, base_color);
1525                        let vp_size = frame.camera.viewport_size;
1526                        let gpu = self.resources.upload_polyline(device, queue, &polyline, vp_size);
1527                        self.polyline_gpu_data.push(gpu);
1528                    }
1529                    _ => {}
1530                }
1531            }
1532        }
1533
1534        // Cap geometry for section-view cross-section fill.
1535        let mut cap_buffers = Vec::new();
1536        if viewport_fx.cap_fill_enabled {
1537            for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
1538                if let ClipShape::Plane { normal, distance, cap_color } = obj.shape {
1539                    let plane_n = glam::Vec3::from(normal);
1540                    for item in scene_items.iter().filter(|i| i.visible) {
1541                        let Some(mesh) = self
1542                            .resources
1543                            .mesh_store
1544                            .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1545                        else {
1546                            continue;
1547                        };
1548                        let model = glam::Mat4::from_cols_array_2d(&item.model);
1549                        let world_aabb = mesh.aabb.transformed(&model);
1550                        if !world_aabb.intersects_plane(plane_n, distance) {
1551                            continue;
1552                        }
1553                        let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices) else {
1554                            continue;
1555                        };
1556                        if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
1557                            pos,
1558                            idx,
1559                            &model,
1560                            plane_n,
1561                            distance,
1562                        ) {
1563                            let bc = item.material.base_color;
1564                            let color = cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
1565                            let buf = self.resources.upload_cap_geometry(device, &cap, color);
1566                            cap_buffers.push(buf);
1567                        }
1568                    }
1569                }
1570            }
1571        }
1572
1573        // Axes indicator geometry (built here, written to slot buffer below).
1574        let axes_verts = if frame.viewport.show_axes_indicator
1575            && frame.camera.viewport_size[0] > 0.0
1576            && frame.camera.viewport_size[1] > 0.0
1577        {
1578            let verts = crate::widgets::axes_indicator::build_axes_geometry(
1579                frame.camera.viewport_size[0],
1580                frame.camera.viewport_size[1],
1581                frame.camera.render_camera.orientation,
1582            );
1583            if verts.is_empty() { None } else { Some(verts) }
1584        } else {
1585            None
1586        };
1587
1588        // Gizmo mesh + uniform (built here, written to slot buffers below).
1589        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
1590            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
1591                frame.interaction.gizmo_mode,
1592                frame.interaction.gizmo_hovered,
1593                frame.interaction.gizmo_space_orientation,
1594            );
1595            (verts, indices, model)
1596        });
1597
1598        // ------------------------------------------------------------------
1599        // Assign all interaction state to the per-viewport slot.
1600        // ------------------------------------------------------------------
1601        {
1602            let slot = &mut self.viewport_slots[vp_idx];
1603            slot.outline_object_buffers = outline_object_buffers;
1604            slot.xray_object_buffers = xray_object_buffers;
1605            slot.constraint_line_buffers = constraint_line_buffers;
1606            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
1607            slot.clip_plane_line_buffers = clip_plane_line_buffers;
1608            slot.cap_buffers = cap_buffers;
1609
1610            // Axes: resize buffer if needed, then upload.
1611            if let Some(verts) = axes_verts {
1612                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
1613                if byte_size > slot.axes_vertex_buffer.size() {
1614                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1615                        label: Some("vp_axes_vertex_buf"),
1616                        size: byte_size,
1617                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1618                        mapped_at_creation: false,
1619                    });
1620                }
1621                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
1622                slot.axes_vertex_count = verts.len() as u32;
1623            } else {
1624                slot.axes_vertex_count = 0;
1625            }
1626
1627            // Gizmo: resize buffers if needed, then upload mesh + uniform.
1628            if let Some((verts, indices, model)) = gizmo_update {
1629                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
1630                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
1631                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
1632                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1633                        label: Some("vp_gizmo_vertex_buf"),
1634                        size: vert_bytes.len() as u64,
1635                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1636                        mapped_at_creation: false,
1637                    });
1638                }
1639                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
1640                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1641                        label: Some("vp_gizmo_index_buf"),
1642                        size: idx_bytes.len() as u64,
1643                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1644                        mapped_at_creation: false,
1645                    });
1646                }
1647                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
1648                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
1649                slot.gizmo_index_count = indices.len() as u32;
1650                let uniform = crate::interaction::gizmo::GizmoUniform {
1651                    model: model.to_cols_array_2d(),
1652                };
1653                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
1654            }
1655        }
1656
1657        // ------------------------------------------------------------------
1658        // Outline offscreen pass — render stencil-based outline ring into a
1659        // dedicated RGBA texture so the paint() path can composite it later.
1660        //
1661        // Uses the per-viewport camera bind group and per-viewport HDR views.
1662        // ------------------------------------------------------------------
1663        if frame.interaction.outline_selected
1664            && !self.viewport_slots[vp_idx]
1665                .outline_object_buffers
1666                .is_empty()
1667        {
1668            let w = frame.camera.viewport_size[0] as u32;
1669            let h = frame.camera.viewport_size[1] as u32;
1670
1671            // Ensure per-viewport HDR state exists (provides outline color + depth views).
1672            self.ensure_viewport_hdr(device, queue, vp_idx, w.max(1), h.max(1), frame.effects.post_process.ssaa_factor.max(1));
1673
1674            // Extract raw pointers for slot fields needed inside the render pass
1675            // alongside &self.resources borrows (borrow-checker trick: slot and resources
1676            // are independent fields of self).
1677            let slot_ref = &self.viewport_slots[vp_idx];
1678            let outlines_ptr = &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
1679            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
1680            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
1681            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
1682            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
1683            // SAFETY: slot fields remain valid for the duration of this function;
1684            // no other code modifies these fields here.
1685            let (outlines, camera_bg, color_view, depth_view) = unsafe {
1686                (
1687                    &*outlines_ptr,
1688                    &*camera_bg_ptr,
1689                    &*color_view_ptr,
1690                    &*depth_view_ptr,
1691                )
1692            };
1693
1694            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1695                label: Some("outline_offscreen_encoder"),
1696            });
1697            {
1698                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1699                    label: Some("outline_offscreen_pass"),
1700                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1701                        view: color_view,
1702                        resolve_target: None,
1703                        ops: wgpu::Operations {
1704                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1705                            store: wgpu::StoreOp::Store,
1706                        },
1707                        depth_slice: None,
1708                    })],
1709                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1710                        view: depth_view,
1711                        depth_ops: Some(wgpu::Operations {
1712                            load: wgpu::LoadOp::Clear(1.0),
1713                            store: wgpu::StoreOp::Discard,
1714                        }),
1715                        stencil_ops: Some(wgpu::Operations {
1716                            load: wgpu::LoadOp::Clear(0),
1717                            store: wgpu::StoreOp::Discard,
1718                        }),
1719                    }),
1720                    timestamp_writes: None,
1721                    occlusion_query_set: None,
1722                });
1723
1724                // Pass 1: write stencil=1 for selected objects.
1725                pass.set_stencil_reference(1);
1726                pass.set_bind_group(0, camera_bg, &[]);
1727                for outlined in outlines {
1728                    let Some(mesh) = self
1729                        .resources
1730                        .mesh_store
1731                        .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1732                    else {
1733                        continue;
1734                    };
1735                    let pipeline = if outlined.two_sided {
1736                        &self.resources.stencil_write_two_sided_pipeline
1737                    } else {
1738                        &self.resources.stencil_write_pipeline
1739                    };
1740                    pass.set_pipeline(pipeline);
1741                    pass.set_bind_group(1, &outlined.stencil_bind_group, &[]);
1742                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1743                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1744                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1745                }
1746
1747                // Pass 2: draw expanded outline ring where stencil != 1.
1748                pass.set_pipeline(&self.resources.outline_pipeline);
1749                pass.set_stencil_reference(1);
1750                for outlined in outlines {
1751                    let Some(mesh) = self
1752                        .resources
1753                        .mesh_store
1754                        .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1755                    else {
1756                        continue;
1757                    };
1758                    pass.set_bind_group(1, &outlined.outline_bind_group, &[]);
1759                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1760                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1761                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1762                }
1763            }
1764            queue.submit(std::iter::once(encoder.finish()));
1765        }
1766    }
1767
1768    /// Upload per-frame data to GPU buffers and render the shadow pass.
1769    /// Call before `paint()`.
1770    pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, frame: &FrameData) {
1771        let (scene_fx, viewport_fx) = frame.effects.split();
1772        self.prepare_scene_internal(device, queue, frame, &scene_fx);
1773        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
1774    }
1775}
1776
1777// ---------------------------------------------------------------------------
1778// Clip boundary wireframe helpers (used by prepare_viewport_internal)
1779// ---------------------------------------------------------------------------
1780
1781/// Wireframe outline for a clip box (12 edges as 2-point polyline strips).
1782fn clip_box_outline(
1783    center: [f32; 3],
1784    half: [f32; 3],
1785    orientation: [[f32; 3]; 3],
1786    color: [f32; 4],
1787) -> PolylineItem {
1788    let ax = glam::Vec3::from(orientation[0]) * half[0];
1789    let ay = glam::Vec3::from(orientation[1]) * half[1];
1790    let az = glam::Vec3::from(orientation[2]) * half[2];
1791    let c = glam::Vec3::from(center);
1792
1793    let corners = [
1794        c - ax - ay - az,
1795        c + ax - ay - az,
1796        c + ax + ay - az,
1797        c - ax + ay - az,
1798        c - ax - ay + az,
1799        c + ax - ay + az,
1800        c + ax + ay + az,
1801        c - ax + ay + az,
1802    ];
1803    let edges: [(usize, usize); 12] = [
1804        (0, 1), (1, 2), (2, 3), (3, 0), // bottom face
1805        (4, 5), (5, 6), (6, 7), (7, 4), // top face
1806        (0, 4), (1, 5), (2, 6), (3, 7), // verticals
1807    ];
1808
1809    let mut positions = Vec::with_capacity(24);
1810    let mut strip_lengths = Vec::with_capacity(12);
1811    for (a, b) in edges {
1812        positions.push(corners[a].to_array());
1813        positions.push(corners[b].to_array());
1814        strip_lengths.push(2u32);
1815    }
1816
1817    let mut item = PolylineItem::default();
1818    item.positions = positions;
1819    item.strip_lengths = strip_lengths;
1820    item.default_color = color;
1821    item.line_width = 2.0;
1822    item
1823}
1824
1825/// Wireframe outline for a clip sphere (three great circles).
1826fn clip_sphere_outline(center: [f32; 3], radius: f32, color: [f32; 4]) -> PolylineItem {
1827    let c = glam::Vec3::from(center);
1828    let segs = 64usize;
1829    let mut positions = Vec::with_capacity((segs + 1) * 3);
1830    let mut strip_lengths = Vec::with_capacity(3);
1831
1832    for axis in 0..3usize {
1833        let start = positions.len();
1834        for i in 0..=segs {
1835            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
1836            let (s, cs) = t.sin_cos();
1837            let p = c + match axis {
1838                0 => glam::Vec3::new(cs * radius, s * radius, 0.0),
1839                1 => glam::Vec3::new(cs * radius, 0.0, s * radius),
1840                _ => glam::Vec3::new(0.0, cs * radius, s * radius),
1841            };
1842            positions.push(p.to_array());
1843        }
1844        strip_lengths.push((positions.len() - start) as u32);
1845    }
1846
1847    let mut item = PolylineItem::default();
1848    item.positions = positions;
1849    item.strip_lengths = strip_lengths;
1850    item.default_color = color;
1851    item.line_width = 2.0;
1852    item
1853}