Skip to main content

viewport_lib/resources/scivis/
tube.rs

1use super::*;
2
3/// Streamtube (connected tube mesh) pipelines and layout. Lazily built; the
4/// uploaded streamtube/tube data lives in separate flat stores.
5#[derive(Default)]
6pub(crate) struct StreamtubeResources {
7    /// Streamtube render pipeline. None until first streamtube item is submitted.
8    pub(crate) pipeline: Option<DualPipeline>,
9    /// Streamtube wireframe pipeline (LineList topology, cull_mode None).
10    pub(crate) wireframe_pipeline: Option<DualPipeline>,
11    /// Bind group layout for streamtube uniforms (group 1).
12    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
13}
14
15/// Ribbon pipelines (one per blend mode) and layout.
16#[derive(Default)]
17pub(crate) struct RibbonResources {
18    /// Ribbon pipeline: alpha blend, depth write enabled. Default for plain ribbons.
19    pub(crate) pipeline: Option<DualPipeline>,
20    /// Ribbon pipeline with additive blend and depth write disabled.
21    pub(crate) pipeline_additive: Option<DualPipeline>,
22    /// Ribbon pipeline with premultiplied-alpha blend and depth write disabled.
23    pub(crate) pipeline_premultiplied: Option<DualPipeline>,
24    /// Ribbon wireframe pipeline (LineList topology, cull_mode None).
25    pub(crate) wireframe_pipeline: Option<DualPipeline>,
26    /// Bind group layout for ribbons (group 1): uniform + optional streak texture + sampler.
27    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
28}
29
30impl DeviceResources {
31    /// Lazily create the streamtube render pipeline (connected tube mesh, TriangleList).
32    ///
33    /// No-op if already created. Called from `prepare()` when `frame.scene.streamtube_items`
34    /// is non-empty.
35    pub(crate) fn ensure_streamtube_pipeline(&mut self, device: &wgpu::Device) {
36        if self.streamtube.pipeline.is_some() {
37            return;
38        }
39
40        let streamtube_bgl = crate::resources::builders::uniform_bgl(
41            device,
42            "streamtube_bgl",
43            wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
44        );
45
46        let shader = crate::resources::builders::wgsl_module(
47            device,
48            "streamtube_shader",
49            crate::resources::builders::wgsl_source!("streamtube"),
50        );
51
52        let layout = crate::resources::builders::standard_scene_layout(
53            device,
54            "streamtube_pipeline_layout",
55            &self.camera_bind_group_layout,
56            &streamtube_bgl,
57        );
58
59        // Ribbon BGL adds an optional streak texture + sampler alongside the
60        // shared uniform binding. The fragment shader keys off `has_texture`
61        // and falls back to the resolved colour when no texture is bound.
62        let ribbon_bgl = crate::resources::builders::uniform_texture_sampler_bgl(
63            device,
64            "ribbon_bgl",
65            wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
66            wgpu::ShaderStages::FRAGMENT,
67        );
68        let ribbon_layout = crate::resources::builders::standard_scene_layout(
69            device,
70            "ribbon_pipeline_layout",
71            &self.camera_bind_group_layout,
72            &ribbon_bgl,
73        );
74
75        // Ribbon pipeline: same layout, two-sided shader, cull_mode None.
76        let ribbon_shader = crate::resources::builders::wgsl_module(
77            device,
78            "ribbon_shader",
79            crate::resources::builders::wgsl_source!("ribbon"),
80        );
81        let additive_blend = wgpu::BlendState {
82            color: wgpu::BlendComponent {
83                src_factor: wgpu::BlendFactor::One,
84                dst_factor: wgpu::BlendFactor::One,
85                operation: wgpu::BlendOperation::Add,
86            },
87            alpha: wgpu::BlendComponent {
88                src_factor: wgpu::BlendFactor::One,
89                dst_factor: wgpu::BlendFactor::One,
90                operation: wgpu::BlendOperation::Add,
91            },
92        };
93        let premultiplied_blend = wgpu::BlendState {
94            color: wgpu::BlendComponent {
95                src_factor: wgpu::BlendFactor::One,
96                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
97                operation: wgpu::BlendOperation::Add,
98            },
99            alpha: wgpu::BlendComponent {
100                src_factor: wgpu::BlendFactor::One,
101                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
102                operation: wgpu::BlendOperation::Add,
103            },
104        };
105        use crate::resources::builders::{DualPipelineDesc, build_dual_pipeline};
106        self.streamtube.bgl = Some(streamtube_bgl);
107        self.ribbon.bgl = Some(ribbon_bgl);
108        self.streamtube.pipeline = Some(build_dual_pipeline(
109            device,
110            &DualPipelineDesc {
111                label: "streamtube_pipeline",
112                layout: &layout,
113                shader: &shader,
114                vertex_entry: "vs_main",
115                fragment_entry: "fs_main",
116                vertex_buffers: &[Vertex::buffer_layout()],
117                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
118                topology: wgpu::PrimitiveTopology::TriangleList,
119                cull_mode: Some(wgpu::Face::Back),
120                depth_write: true,
121                depth_compare: wgpu::CompareFunction::Less,
122                sample_count: self.sample_count,
123                ldr_format: self.target_format,
124            },
125        ));
126        // Wireframe: same shader and bind groups as the solid tube, but LineList
127        // topology and no back-face culling so edges on both sides are visible.
128        self.streamtube.wireframe_pipeline = Some(build_dual_pipeline(
129            device,
130            &DualPipelineDesc {
131                label: "streamtube_wireframe_pipeline",
132                layout: &layout,
133                shader: &shader,
134                vertex_entry: "vs_main",
135                fragment_entry: "fs_main",
136                vertex_buffers: &[Vertex::buffer_layout()],
137                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
138                topology: wgpu::PrimitiveTopology::LineList,
139                cull_mode: None,
140                depth_write: true,
141                depth_compare: wgpu::CompareFunction::Less,
142                sample_count: self.sample_count,
143                ldr_format: self.target_format,
144            },
145        ));
146        // Additive and premultiplied ribbons are typically used for emissive
147        // trails; depth write is disabled so successive segments accumulate
148        // rather than clipping each other when they overlap.
149        self.ribbon.pipeline = Some(build_dual_pipeline(
150            device,
151            &DualPipelineDesc {
152                label: "ribbon_pipeline",
153                layout: &ribbon_layout,
154                shader: &ribbon_shader,
155                vertex_entry: "vs_main",
156                fragment_entry: "fs_main",
157                vertex_buffers: &[Vertex::buffer_layout()],
158                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
159                topology: wgpu::PrimitiveTopology::TriangleList,
160                cull_mode: None,
161                depth_write: true,
162                depth_compare: wgpu::CompareFunction::Less,
163                sample_count: self.sample_count,
164                ldr_format: self.target_format,
165            },
166        ));
167        self.ribbon.pipeline_additive = Some(build_dual_pipeline(
168            device,
169            &DualPipelineDesc {
170                label: "ribbon_pipeline_additive",
171                layout: &ribbon_layout,
172                shader: &ribbon_shader,
173                vertex_entry: "vs_main",
174                fragment_entry: "fs_main",
175                vertex_buffers: &[Vertex::buffer_layout()],
176                blend: Some(additive_blend),
177                topology: wgpu::PrimitiveTopology::TriangleList,
178                cull_mode: None,
179                depth_write: false,
180                depth_compare: wgpu::CompareFunction::Less,
181                sample_count: self.sample_count,
182                ldr_format: self.target_format,
183            },
184        ));
185        self.ribbon.pipeline_premultiplied = Some(build_dual_pipeline(
186            device,
187            &DualPipelineDesc {
188                label: "ribbon_pipeline_premultiplied",
189                layout: &ribbon_layout,
190                shader: &ribbon_shader,
191                vertex_entry: "vs_main",
192                fragment_entry: "fs_main",
193                vertex_buffers: &[Vertex::buffer_layout()],
194                blend: Some(premultiplied_blend),
195                topology: wgpu::PrimitiveTopology::TriangleList,
196                cull_mode: None,
197                depth_write: false,
198                depth_compare: wgpu::CompareFunction::Less,
199                sample_count: self.sample_count,
200                ldr_format: self.target_format,
201            },
202        ));
203        // Ribbon wireframe: same as tube wireframe but using the ribbon shader.
204        self.ribbon.wireframe_pipeline = Some(build_dual_pipeline(
205            device,
206            &DualPipelineDesc {
207                label: "ribbon_wireframe_pipeline",
208                layout: &ribbon_layout,
209                shader: &ribbon_shader,
210                vertex_entry: "vs_main",
211                fragment_entry: "fs_main",
212                vertex_buffers: &[Vertex::buffer_layout()],
213                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
214                topology: wgpu::PrimitiveTopology::LineList,
215                cull_mode: None,
216                depth_write: true,
217                depth_compare: wgpu::CompareFunction::Less,
218                sample_count: self.sample_count,
219                ldr_format: self.target_format,
220            },
221        ));
222    }
223
224    /// Upload one [`StreamtubeItem`] to the GPU and return draw data.
225    ///
226    /// Generates a connected tube mesh CPU-side using a parallel-transport frame along
227    /// each polyline strip, then uploads the result as a single owned vertex+index buffer.
228    /// Adjacent rings are joined by quads (2 triangles each) giving a smooth, seamless tube
229    /// without the z-fighting or inter-segment gaps that plagued the old instanced approach.
230    pub(crate) fn upload_streamtube_per_frame(
231        &mut self,
232        device: &wgpu::Device,
233        queue: &wgpu::Queue,
234        item: &crate::renderer::StreamtubeItem,
235        wireframe: bool,
236    ) -> StreamtubeGpuData {
237        const SIDES: usize = 12; // tube cross-section resolution
238
239        let radius = item.radius.max(f32::EPSILON);
240
241        let mut verts: Vec<Vertex> = Vec::new();
242        let mut indices: Vec<u32> = Vec::new();
243
244        let positions = &item.positions;
245        let mut strip_start = 0usize;
246
247        for &strip_len in &item.strip_lengths {
248            let strip_len = strip_len as usize;
249            let strip_end = (strip_start + strip_len).min(positions.len());
250            let pts: Vec<glam::Vec3> = positions[strip_start..strip_end]
251                .iter()
252                .map(|&p| glam::Vec3::from(p))
253                .collect();
254            strip_start += strip_len;
255
256            if pts.len() < 2 {
257                continue;
258            }
259
260            // ---- Parallel transport frame ----------------------------------------
261            // Seed: find an initial tangent and an arbitrary perpendicular.
262            let t0 = (pts[1] - pts[0]).normalize_or_zero();
263            if t0.length_squared() < 1e-10 {
264                continue;
265            }
266            // Choose a reference vector not parallel to t0.
267            let ref_v = if t0.x.abs() < 0.9 {
268                glam::Vec3::X
269            } else {
270                glam::Vec3::Y
271            };
272            let mut u = t0.cross(ref_v).normalize(); // initial "up"
273
274            // Emit rings for each point, transporting the frame forward.
275            let ring_base = verts.len() as u32;
276            let n_rings = pts.len();
277
278            for (k, &pt) in pts.iter().enumerate() {
279                // Tangent at this point (forward difference, except at the last point).
280                let tangent = if k + 1 < pts.len() {
281                    (pts[k + 1] - pt).normalize_or_zero()
282                } else {
283                    (pt - pts[k - 1]).normalize_or_zero()
284                };
285
286                // Transport u: project out the component along the new tangent.
287                if k > 0 {
288                    let t_prev = (pts[k] - pts[k - 1]).normalize_or_zero();
289                    // Rodrigues rotation: rotate u by the same angle that t_prev -> tangent.
290                    let axis = t_prev.cross(tangent);
291                    let sin_a = axis.length().min(1.0);
292                    if sin_a > 1e-6 {
293                        let cos_a = t_prev.dot(tangent).clamp(-1.0, 1.0);
294                        let ax = axis / sin_a;
295                        // Rodrigues: u' = u cos(a) + cross(ax, u) sin(a) + ax dot(ax, u) (1 - cos(a))
296                        u = u * cos_a + ax.cross(u) * sin_a + ax * ax.dot(u) * (1.0 - cos_a);
297                        u = u.normalize_or_zero();
298                    }
299                }
300
301                let v = tangent.cross(u).normalize_or_zero();
302
303                // Emit SIDES vertices around the ring.
304                for s in 0..SIDES {
305                    let theta = 2.0 * std::f32::consts::PI * (s as f32) / (SIDES as f32);
306                    let nx = theta.cos() * u.x + theta.sin() * v.x;
307                    let ny = theta.cos() * u.y + theta.sin() * v.y;
308                    let nz = theta.cos() * u.z + theta.sin() * v.z;
309                    let normal = glam::Vec3::new(nx, ny, nz);
310                    let world_pos = pt + normal * radius;
311                    verts.push(Vertex {
312                        position: world_pos.to_array(),
313                        normal: normal.to_array(),
314                        colour: [1.0, 1.0, 1.0, 1.0], // overridden by uniform in shader
315                        uv: [0.0, 0.0],
316                        tangent: [1.0, 0.0, 0.0, 1.0],
317                    });
318                }
319
320                // Emit quad strip between ring k-1 and ring k.
321                // Winding: outward-facing CCW (right-hand rule gives outward normal).
322                // Verified: T1=(r0+s, r0+s1, r1+s) has dot(normal, Y) > 0 for s=0 on Z-axis tube.
323                if k > 0 {
324                    let r0 = ring_base + ((k - 1) * SIDES) as u32;
325                    let r1 = ring_base + (k * SIDES) as u32;
326                    for s in 0..SIDES {
327                        let s1 = (s + 1) % SIDES;
328                        indices.push(r0 + s as u32);
329                        indices.push(r0 + s1 as u32);
330                        indices.push(r1 + s as u32);
331
332                        indices.push(r0 + s1 as u32);
333                        indices.push(r1 + s1 as u32);
334                        indices.push(r1 + s as u32);
335                    }
336                }
337            }
338
339            // End cap (flat fan at last ring, facing forward = outward at tube end).
340            // CCW from the forward direction: (center, s, s1).
341            {
342                let last_ring = ring_base + ((n_rings - 1) * SIDES) as u32;
343                let tangent = (pts[n_rings - 1] - pts[n_rings - 2]).normalize_or_zero();
344                let cap_center_idx = verts.len() as u32;
345                verts.push(Vertex {
346                    position: pts[n_rings - 1].to_array(),
347                    normal: tangent.to_array(),
348                    colour: [1.0, 1.0, 1.0, 1.0],
349                    uv: [0.0, 0.0],
350                    tangent: [1.0, 0.0, 0.0, 1.0],
351                });
352                for s in 0..SIDES {
353                    let s1 = (s + 1) % SIDES;
354                    indices.push(cap_center_idx);
355                    indices.push(last_ring + s as u32);
356                    indices.push(last_ring + s1 as u32);
357                }
358            }
359
360            // Start cap (flat fan at first ring, facing backward = outward at tube start).
361            // CCW from the backward direction = CW from forward = (center, s1, s).
362            {
363                let tangent = (pts[0] - pts[1]).normalize_or_zero();
364                let cap_center_idx = verts.len() as u32;
365                verts.push(Vertex {
366                    position: pts[0].to_array(),
367                    normal: tangent.to_array(),
368                    colour: [1.0, 1.0, 1.0, 1.0],
369                    uv: [0.0, 0.0],
370                    tangent: [1.0, 0.0, 0.0, 1.0],
371                });
372                for s in 0..SIDES {
373                    let s1 = (s + 1) % SIDES;
374                    indices.push(cap_center_idx);
375                    indices.push(ring_base + s1 as u32);
376                    indices.push(ring_base + s as u32);
377                }
378            }
379        }
380
381        // Upload vertex + index buffers.
382        let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
383        let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
384
385        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
386            label: Some("streamtube_vbuf"),
387            size: vert_bytes.len().max(std::mem::size_of::<Vertex>()) as u64,
388            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
389            mapped_at_creation: false,
390        });
391        if !vert_bytes.is_empty() {
392            queue.write_buffer(&vertex_buffer, 0, vert_bytes);
393        }
394
395        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
396            label: Some("streamtube_ibuf"),
397            size: idx_bytes.len().max(12) as u64,
398            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
399            mapped_at_creation: false,
400        });
401        if !idx_bytes.is_empty() {
402            queue.write_buffer(&index_buffer, 0, idx_bytes);
403        }
404
405        let index_count = indices.len() as u32;
406
407        // Edge index buffer: deduplicated triangle edges as line-list pairs for wireframe.
408        let edge_indices = crate::resources::mesh::geometry::generate_edge_indices(&indices);
409        let edge_bytes: &[u8] = bytemuck::cast_slice(&edge_indices);
410        let edge_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
411            label: Some("streamtube_edge_ibuf"),
412            size: edge_bytes.len().max(8) as u64,
413            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
414            mapped_at_creation: false,
415        });
416        if !edge_bytes.is_empty() {
417            queue.write_buffer(&edge_index_buffer, 0, edge_bytes);
418        }
419        let edge_index_count = edge_indices.len() as u32;
420
421        // Uniform buffer: model + colour + radius + use_vertex_colour + unlit + opacity + wireframe.
422        #[repr(C)]
423        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
424        struct StreamtubeUniform {
425            model: [[f32; 4]; 4],
426            colour: [f32; 4],
427            radius: f32,
428            use_vertex_colour: u32,
429            unlit: u32,
430            opacity: f32,
431            wireframe: u32,
432            _pad: [f32; 3],
433        }
434        let uniform_data = StreamtubeUniform {
435            model: item.model,
436            colour: item.colour,
437            radius,
438            use_vertex_colour: 0,
439            unlit: item.settings.unlit as u32,
440            opacity: item.settings.opacity,
441            wireframe: wireframe as u32,
442            _pad: [0.0; 3],
443        };
444        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
445            label: Some("streamtube_uniform_buf"),
446            size: std::mem::size_of::<StreamtubeUniform>() as u64,
447            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
448            mapped_at_creation: false,
449        });
450        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
451
452        let bgl = self
453            .streamtube
454            .bgl
455            .as_ref()
456            .expect("ensure_streamtube_pipeline not called");
457        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
458            label: Some("streamtube_uniform_bg"),
459            layout: bgl,
460            entries: &[wgpu::BindGroupEntry {
461                binding: 0,
462                resource: uniform_buf.as_entire_binding(),
463            }],
464        });
465
466        StreamtubeGpuData {
467            vertex_buffer,
468            index_buffer,
469            index_count,
470            edge_index_buffer,
471            edge_index_count,
472            wireframe,
473            uniform_bind_group,
474            blend: crate::renderer::SpriteBlend::AlphaBlend,
475            _uniform_buf: uniform_buf,
476        }
477    }
478
479    /// Pre-upload a streamtube and return a typed handle.
480    ///
481    /// Submit a [`StreamtubeRefItem`](crate::renderer::StreamtubeRefItem) on
482    /// `SceneFrame::streamtube_refs` each frame to draw the tube at a
483    /// per-frame model transform without rebuilding its mesh.
484    pub fn upload_streamtube(
485        &mut self,
486        device: &wgpu::Device,
487        queue: &wgpu::Queue,
488        item: &crate::renderer::StreamtubeItem,
489    ) -> crate::resources::StreamtubeId {
490        self.ensure_streamtube_pipeline(device);
491        let gpu = self.upload_streamtube_per_frame(device, queue, item, false);
492        self.content.streamtube_store.insert(gpu)
493    }
494
495    /// Remove a pre-uploaded streamtube.
496    pub fn drop_streamtube(&mut self, id: crate::resources::StreamtubeId) -> bool {
497        self.content.streamtube_store.remove(id)
498    }
499
500    /// Replace the geometry of a pre-uploaded streamtube, keeping the same id.
501    pub fn replace_streamtube(
502        &mut self,
503        device: &wgpu::Device,
504        queue: &wgpu::Queue,
505        id: crate::resources::StreamtubeId,
506        item: &crate::renderer::StreamtubeItem,
507    ) -> bool {
508        if !self.content.streamtube_store.contains(id) {
509            return false;
510        }
511        self.ensure_streamtube_pipeline(device);
512        let gpu = self.upload_streamtube_per_frame(device, queue, item, false);
513        self.content.streamtube_store.replace(id, gpu)
514    }
515
516    // -------------------------------------------------------------------------
517    // General Tube representation
518    // -------------------------------------------------------------------------
519
520    /// Upload one [`TubeItem`] to the GPU and return draw data.
521    ///
522    /// Generates a connected tube mesh CPU-side using a parallel-transport frame.
523    /// Scalar values are baked into per-vertex colours using the CPU-side colourmap copy.
524    /// Uses the same streamtube pipeline; sets `use_vertex_colour=1` when scalars are present.
525    pub(crate) fn upload_tube_per_frame(
526        &mut self,
527        device: &wgpu::Device,
528        queue: &wgpu::Queue,
529        item: &crate::renderer::TubeItem,
530        wireframe: bool,
531    ) -> StreamtubeGpuData {
532        let sides = (item.sides.max(3)) as usize;
533
534        // Resolve scalar-to-colour mapping upfront if scalars are provided.
535        let (use_vertex_colour, lut_rgba): (u32, Option<[[u8; 4]; 256]>) =
536            if !item.scalars.is_empty() {
537                let lut = self
538                    .content
539                    .builtin_colourmap_ids
540                    .and_then(|ids| {
541                        let preset_id = item
542                            .colourmap_id
543                            .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
544                        self.content.colourmaps_cpu.get(preset_id.0).copied()
545                    })
546                    .unwrap_or([[128u8; 4]; 256]);
547                (1, Some(lut))
548            } else {
549                (0, None)
550            };
551
552        let scalar_min = item
553            .scalar_range
554            .map(|r| r.0)
555            .unwrap_or_else(|| item.scalars.iter().cloned().fold(f32::INFINITY, f32::min));
556        let scalar_max = item.scalar_range.map(|r| r.1).unwrap_or_else(|| {
557            item.scalars
558                .iter()
559                .cloned()
560                .fold(f32::NEG_INFINITY, f32::max)
561        });
562        let scalar_range = (scalar_max - scalar_min).max(f32::EPSILON);
563
564        // Helper: map a scalar value to an RGBA f32 colour from the LUT.
565        let scalar_to_colour = |idx: usize| -> [f32; 4] {
566            if let Some(ref lut) = lut_rgba {
567                let s = *item.scalars.get(idx).unwrap_or(&0.0);
568                let t = ((s - scalar_min) / scalar_range).clamp(0.0, 1.0);
569                let lut_idx = ((t * 255.0).round() as usize).min(255);
570                let c = lut[lut_idx];
571                [
572                    c[0] as f32 / 255.0,
573                    c[1] as f32 / 255.0,
574                    c[2] as f32 / 255.0,
575                    c[3] as f32 / 255.0,
576                ]
577            } else {
578                item.colour
579            }
580        };
581
582        let mut verts: Vec<Vertex> = Vec::new();
583        let mut indices: Vec<u32> = Vec::new();
584
585        let positions = &item.positions;
586        let mut strip_start = 0usize;
587
588        for &strip_len in &item.strip_lengths {
589            let strip_len = strip_len as usize;
590            let strip_end = (strip_start + strip_len).min(positions.len());
591            let pts: Vec<glam::Vec3> = positions[strip_start..strip_end]
592                .iter()
593                .map(|&p| glam::Vec3::from(p))
594                .collect();
595            let pts_scalar_start = strip_start;
596            strip_start += strip_len;
597
598            if pts.len() < 2 {
599                continue;
600            }
601
602            // Parallel transport frame (same as upload_streamtube).
603            let t0 = (pts[1] - pts[0]).normalize_or_zero();
604            if t0.length_squared() < 1e-10 {
605                continue;
606            }
607            let ref_v = if t0.x.abs() < 0.9 {
608                glam::Vec3::X
609            } else {
610                glam::Vec3::Y
611            };
612            let mut u = t0.cross(ref_v).normalize();
613
614            let ring_base = verts.len() as u32;
615            let n_rings = pts.len();
616
617            for (k, &pt) in pts.iter().enumerate() {
618                let tangent = if k + 1 < pts.len() {
619                    (pts[k + 1] - pt).normalize_or_zero()
620                } else {
621                    (pt - pts[k - 1]).normalize_or_zero()
622                };
623
624                if k > 0 {
625                    let t_prev = (pts[k] - pts[k - 1]).normalize_or_zero();
626                    let axis = t_prev.cross(tangent);
627                    let sin_a = axis.length().min(1.0);
628                    if sin_a > 1e-6 {
629                        let cos_a = t_prev.dot(tangent).clamp(-1.0, 1.0);
630                        let ax = axis / sin_a;
631                        u = u * cos_a + ax.cross(u) * sin_a + ax * ax.dot(u) * (1.0 - cos_a);
632                        u = u.normalize_or_zero();
633                    }
634                }
635
636                let v = tangent.cross(u).normalize_or_zero();
637
638                // Per-point radius: from radius_attribute if provided, else uniform radius.
639                let point_radius = item
640                    .radius_attribute
641                    .as_ref()
642                    .and_then(|ra| ra.get(pts_scalar_start + k).copied())
643                    .unwrap_or(item.radius)
644                    .max(f32::EPSILON);
645
646                let vertex_colour = scalar_to_colour(pts_scalar_start + k);
647
648                for s in 0..sides {
649                    let theta = 2.0 * std::f32::consts::PI * (s as f32) / (sides as f32);
650                    let nx = theta.cos() * u.x + theta.sin() * v.x;
651                    let ny = theta.cos() * u.y + theta.sin() * v.y;
652                    let nz = theta.cos() * u.z + theta.sin() * v.z;
653                    let normal = glam::Vec3::new(nx, ny, nz);
654                    let world_pos = pt + normal * point_radius;
655                    verts.push(Vertex {
656                        position: world_pos.to_array(),
657                        normal: normal.to_array(),
658                        colour: vertex_colour,
659                        uv: [0.0, 0.0],
660                        tangent: [1.0, 0.0, 0.0, 1.0],
661                    });
662                }
663
664                if k > 0 {
665                    let r0 = ring_base + ((k - 1) * sides) as u32;
666                    let r1 = ring_base + (k * sides) as u32;
667                    for s in 0..sides {
668                        let s1 = (s + 1) % sides;
669                        indices.push(r0 + s as u32);
670                        indices.push(r0 + s1 as u32);
671                        indices.push(r1 + s as u32);
672
673                        indices.push(r0 + s1 as u32);
674                        indices.push(r1 + s1 as u32);
675                        indices.push(r1 + s as u32);
676                    }
677                }
678            }
679
680            // End cap.
681            {
682                let last_ring = ring_base + ((n_rings - 1) * sides) as u32;
683                let tangent = (pts[n_rings - 1] - pts[n_rings - 2]).normalize_or_zero();
684                let cap_colour = scalar_to_colour(pts_scalar_start + n_rings - 1);
685                let cap_center_idx = verts.len() as u32;
686                verts.push(Vertex {
687                    position: pts[n_rings - 1].to_array(),
688                    normal: tangent.to_array(),
689                    colour: cap_colour,
690                    uv: [0.0, 0.0],
691                    tangent: [1.0, 0.0, 0.0, 1.0],
692                });
693                for s in 0..sides {
694                    let s1 = (s + 1) % sides;
695                    indices.push(cap_center_idx);
696                    indices.push(last_ring + s as u32);
697                    indices.push(last_ring + s1 as u32);
698                }
699            }
700
701            // Start cap.
702            {
703                let tangent = (pts[0] - pts[1]).normalize_or_zero();
704                let cap_colour = scalar_to_colour(pts_scalar_start);
705                let cap_center_idx = verts.len() as u32;
706                verts.push(Vertex {
707                    position: pts[0].to_array(),
708                    normal: tangent.to_array(),
709                    colour: cap_colour,
710                    uv: [0.0, 0.0],
711                    tangent: [1.0, 0.0, 0.0, 1.0],
712                });
713                for s in 0..sides {
714                    let s1 = (s + 1) % sides;
715                    indices.push(cap_center_idx);
716                    indices.push(ring_base + s1 as u32);
717                    indices.push(ring_base + s as u32);
718                }
719            }
720        }
721
722        // Upload vertex + index buffers.
723        let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
724        let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
725
726        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
727            label: Some("tube_vbuf"),
728            size: vert_bytes.len().max(std::mem::size_of::<Vertex>()) as u64,
729            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
730            mapped_at_creation: false,
731        });
732        if !vert_bytes.is_empty() {
733            queue.write_buffer(&vertex_buffer, 0, vert_bytes);
734        }
735
736        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
737            label: Some("tube_ibuf"),
738            size: idx_bytes.len().max(12) as u64,
739            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
740            mapped_at_creation: false,
741        });
742        if !idx_bytes.is_empty() {
743            queue.write_buffer(&index_buffer, 0, idx_bytes);
744        }
745
746        let index_count = indices.len() as u32;
747
748        let edge_indices = crate::resources::mesh::geometry::generate_edge_indices(&indices);
749        let edge_bytes: &[u8] = bytemuck::cast_slice(&edge_indices);
750        let edge_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
751            label: Some("tube_edge_ibuf"),
752            size: edge_bytes.len().max(8) as u64,
753            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
754            mapped_at_creation: false,
755        });
756        if !edge_bytes.is_empty() {
757            queue.write_buffer(&edge_index_buffer, 0, edge_bytes);
758        }
759        let edge_index_count = edge_indices.len() as u32;
760
761        #[repr(C)]
762        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
763        struct TubeUniform {
764            model: [[f32; 4]; 4],
765            colour: [f32; 4],
766            radius: f32,
767            use_vertex_colour: u32,
768            unlit: u32,
769            opacity: f32,
770            wireframe: u32,
771            _pad: [f32; 3],
772        }
773        let uniform_data = TubeUniform {
774            model: item.model,
775            colour: item.colour,
776            radius: item.radius.max(f32::EPSILON),
777            use_vertex_colour,
778            unlit: item.settings.unlit as u32,
779            opacity: item.settings.opacity,
780            wireframe: wireframe as u32,
781            _pad: [0.0; 3],
782        };
783        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
784            label: Some("tube_uniform_buf"),
785            size: std::mem::size_of::<TubeUniform>() as u64,
786            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
787            mapped_at_creation: false,
788        });
789        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
790
791        let bgl = self
792            .streamtube
793            .bgl
794            .as_ref()
795            .expect("ensure_streamtube_pipeline not called");
796        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
797            label: Some("tube_uniform_bg"),
798            layout: bgl,
799            entries: &[wgpu::BindGroupEntry {
800                binding: 0,
801                resource: uniform_buf.as_entire_binding(),
802            }],
803        });
804
805        StreamtubeGpuData {
806            vertex_buffer,
807            index_buffer,
808            index_count,
809            edge_index_buffer,
810            edge_index_count,
811            wireframe,
812            uniform_bind_group,
813            blend: crate::renderer::SpriteBlend::AlphaBlend,
814            _uniform_buf: uniform_buf,
815        }
816    }
817
818    /// Pre-upload a general tube and return a typed handle.
819    pub fn upload_tube(
820        &mut self,
821        device: &wgpu::Device,
822        queue: &wgpu::Queue,
823        item: &crate::renderer::TubeItem,
824    ) -> crate::resources::TubeId {
825        self.ensure_streamtube_pipeline(device);
826        let gpu = self.upload_tube_per_frame(device, queue, item, false);
827        self.content.tube_store.insert(gpu)
828    }
829
830    /// Remove a pre-uploaded tube.
831    pub fn drop_tube(&mut self, id: crate::resources::TubeId) -> bool {
832        self.content.tube_store.remove(id)
833    }
834
835    /// Replace the geometry of a pre-uploaded tube, keeping the same id.
836    pub fn replace_tube(
837        &mut self,
838        device: &wgpu::Device,
839        queue: &wgpu::Queue,
840        id: crate::resources::TubeId,
841        item: &crate::renderer::TubeItem,
842    ) -> bool {
843        if !self.content.tube_store.contains(id) {
844            return false;
845        }
846        self.ensure_streamtube_pipeline(device);
847        let gpu = self.upload_tube_per_frame(device, queue, item, false);
848        self.content.tube_store.replace(id, gpu)
849    }
850
851    // -------------------------------------------------------------------------
852    // Ribbon representation
853    // -------------------------------------------------------------------------
854
855    /// Build and upload GPU data for a `RibbonItem`.
856    ///
857    /// Each strip is swept as a flat quad surface. Two vertices are generated per
858    /// point (left and right edges), connected as a triangle strip. The normal is
859    /// the cross product of the tangent and the lateral direction `u`.
860    pub(crate) fn upload_ribbon_per_frame(
861        &mut self,
862        device: &wgpu::Device,
863        queue: &wgpu::Queue,
864        item: &crate::renderer::RibbonItem,
865        wireframe: bool,
866    ) -> StreamtubeGpuData {
867        // Per-vertex RGBA (`colour_attribute`) takes precedence over the
868        // scalar+LUT path and the flat `colour` fallback. Trails typically
869        // drive only the alpha channel to fade along their length.
870        let has_colour_attribute = !item.colour_attribute.is_empty();
871
872        // Resolve LUT for scalar colouring.
873        let (use_vertex_colour, lut_rgba): (u32, Option<[[u8; 4]; 256]>) = if has_colour_attribute {
874            (1, None)
875        } else if !item.scalars.is_empty() {
876            let lut = self
877                .content
878                .builtin_colourmap_ids
879                .and_then(|ids| {
880                    let preset_id = item
881                        .colourmap_id
882                        .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
883                    self.content.colourmaps_cpu.get(preset_id.0).copied()
884                })
885                .unwrap_or([[128u8; 4]; 256]);
886            (1, Some(lut))
887        } else {
888            (0, None)
889        };
890
891        let scalar_min = item
892            .scalar_range
893            .map(|r| r.0)
894            .unwrap_or_else(|| item.scalars.iter().cloned().fold(f32::INFINITY, f32::min));
895        let scalar_max = item.scalar_range.map(|r| r.1).unwrap_or_else(|| {
896            item.scalars
897                .iter()
898                .cloned()
899                .fold(f32::NEG_INFINITY, f32::max)
900        });
901        let scalar_range = (scalar_max - scalar_min).max(f32::EPSILON);
902
903        let scalar_to_colour = |idx: usize| -> [f32; 4] {
904            if has_colour_attribute {
905                return item
906                    .colour_attribute
907                    .get(idx)
908                    .copied()
909                    .unwrap_or(item.colour);
910            }
911            if let Some(ref lut) = lut_rgba {
912                let s = *item.scalars.get(idx).unwrap_or(&0.0);
913                let t = ((s - scalar_min) / scalar_range).clamp(0.0, 1.0);
914                let lut_idx = ((t * 255.0).round() as usize).min(255);
915                let c = lut[lut_idx];
916                [
917                    c[0] as f32 / 255.0,
918                    c[1] as f32 / 255.0,
919                    c[2] as f32 / 255.0,
920                    c[3] as f32 / 255.0,
921                ]
922            } else {
923                item.colour
924            }
925        };
926
927        let mut verts: Vec<Vertex> = Vec::new();
928        let mut indices: Vec<u32> = Vec::new();
929
930        let positions = &item.positions;
931        let mut strip_start = 0usize;
932
933        for &strip_len in &item.strip_lengths {
934            let strip_len = strip_len as usize;
935            let strip_end = (strip_start + strip_len).min(positions.len());
936            let pts: Vec<glam::Vec3> = positions[strip_start..strip_end]
937                .iter()
938                .map(|&p| glam::Vec3::from(p))
939                .collect();
940            let pts_start = strip_start;
941            strip_start += strip_len;
942
943            if pts.len() < 2 {
944                continue;
945            }
946
947            // Build parallel transport frame.
948            let t0 = (pts[1] - pts[0]).normalize_or_zero();
949            if t0.length_squared() < 1e-10 {
950                continue;
951            }
952            let ref_v = if t0.x.abs() < 0.9 {
953                glam::Vec3::X
954            } else {
955                glam::Vec3::Y
956            };
957            let mut u = t0.cross(ref_v).normalize();
958
959            // Per-vertex u along the strip. Defaults to cumulative-arc-length
960            // normalised to [0, 1] when the host did not supply a `u_attribute`.
961            let mut strip_u: Vec<f32> = Vec::with_capacity(pts.len());
962            if item.u_attribute.is_empty() {
963                let mut cum = 0.0_f32;
964                strip_u.push(0.0);
965                for k in 1..pts.len() {
966                    cum += (pts[k] - pts[k - 1]).length();
967                    strip_u.push(cum);
968                }
969                let total = strip_u.last().copied().unwrap_or(1.0).max(1e-6);
970                for v in &mut strip_u {
971                    *v /= total;
972                }
973            } else {
974                for k in 0..pts.len() {
975                    strip_u.push(*item.u_attribute.get(pts_start + k).unwrap_or(&0.0));
976                }
977            }
978
979            let base = verts.len() as u32;
980
981            for (k, &pt) in pts.iter().enumerate() {
982                let tangent = if k + 1 < pts.len() {
983                    (pts[k + 1] - pt).normalize_or_zero()
984                } else {
985                    (pt - pts[k - 1]).normalize_or_zero()
986                };
987
988                // Parallel transport: rotate u to stay perpendicular to new tangent.
989                if k > 0 {
990                    let t_prev = (pts[k] - pts[k - 1]).normalize_or_zero();
991                    let axis = t_prev.cross(tangent);
992                    let sin_a = axis.length().min(1.0);
993                    if sin_a > 1e-6 {
994                        let cos_a = t_prev.dot(tangent).clamp(-1.0, 1.0);
995                        let ax = axis / sin_a;
996                        u = u * cos_a + ax.cross(u) * sin_a + ax * ax.dot(u) * (1.0 - cos_a);
997                        u = u.normalize_or_zero();
998                    }
999                }
1000
1001                // If twist_attribute provided, align u with projection of that vector onto
1002                // the plane perpendicular to the tangent.
1003                let mut lateral = u;
1004                if let Some(ref twist) = item.twist_attribute {
1005                    if let Some(&tv) = twist.get(pts_start + k) {
1006                        let tv = glam::Vec3::from(tv);
1007                        let proj = tv - tangent * tangent.dot(tv);
1008                        if proj.length_squared() > 1e-10 {
1009                            lateral = proj.normalize();
1010                        }
1011                    }
1012                }
1013
1014                let normal = tangent.cross(lateral).normalize_or_zero();
1015                let half_w = item
1016                    .width_attribute
1017                    .as_ref()
1018                    .and_then(|wa| wa.get(pts_start + k).copied())
1019                    .unwrap_or(item.width)
1020                    * 0.5;
1021                let colour = scalar_to_colour(pts_start + k);
1022
1023                let uval = strip_u[k];
1024                // Left edge vertex. `uv.x` runs along the strip; `uv.y` is the
1025                // cross-strip coordinate that picks the left or right edge.
1026                verts.push(Vertex {
1027                    position: (pt + lateral * half_w).to_array(),
1028                    normal: normal.to_array(),
1029                    colour,
1030                    uv: [uval, 0.0],
1031                    tangent: [1.0, 0.0, 0.0, 1.0],
1032                });
1033                // Right edge vertex.
1034                verts.push(Vertex {
1035                    position: (pt - lateral * half_w).to_array(),
1036                    normal: normal.to_array(),
1037                    colour,
1038                    uv: [uval, 1.0],
1039                    tangent: [1.0, 0.0, 0.0, 1.0],
1040                });
1041
1042                // Connect to previous pair as two triangles.
1043                if k > 0 {
1044                    let r0 = base + ((k - 1) * 2) as u32;
1045                    let r1 = base + (k * 2) as u32;
1046                    // Triangle 1: r0+0, r0+1, r1+0
1047                    indices.push(r0);
1048                    indices.push(r0 + 1);
1049                    indices.push(r1);
1050                    // Triangle 2: r0+1, r1+1, r1+0
1051                    indices.push(r0 + 1);
1052                    indices.push(r1 + 1);
1053                    indices.push(r1);
1054                }
1055            }
1056        }
1057
1058        // Upload vertex + index buffers.
1059        let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
1060        let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
1061
1062        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1063            label: Some("ribbon_vbuf"),
1064            size: vert_bytes.len().max(std::mem::size_of::<Vertex>()) as u64,
1065            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1066            mapped_at_creation: false,
1067        });
1068        if !vert_bytes.is_empty() {
1069            queue.write_buffer(&vertex_buffer, 0, vert_bytes);
1070        }
1071
1072        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1073            label: Some("ribbon_ibuf"),
1074            size: idx_bytes.len().max(12) as u64,
1075            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1076            mapped_at_creation: false,
1077        });
1078        if !idx_bytes.is_empty() {
1079            queue.write_buffer(&index_buffer, 0, idx_bytes);
1080        }
1081
1082        let index_count = indices.len() as u32;
1083
1084        let edge_indices = crate::resources::mesh::geometry::generate_edge_indices(&indices);
1085        let edge_bytes: &[u8] = bytemuck::cast_slice(&edge_indices);
1086        let edge_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1087            label: Some("ribbon_edge_ibuf"),
1088            size: edge_bytes.len().max(8) as u64,
1089            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1090            mapped_at_creation: false,
1091        });
1092        if !edge_bytes.is_empty() {
1093            queue.write_buffer(&edge_index_buffer, 0, edge_bytes);
1094        }
1095        let edge_index_count = edge_indices.len() as u32;
1096
1097        #[repr(C)]
1098        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
1099        struct RibbonUniform {
1100            model: [[f32; 4]; 4],
1101            colour: [f32; 4],
1102            radius: f32,
1103            use_vertex_colour: u32,
1104            unlit: u32,
1105            opacity: f32,
1106            wireframe: u32,
1107            has_texture: u32,
1108            _pad: [f32; 2],
1109        }
1110        let (texture_view, has_texture): (&wgpu::TextureView, u32) =
1111            if let Some(id) = item.texture_id {
1112                if let Some(tex) = self.content.textures.get(id) {
1113                    (&tex.view, 1)
1114                } else {
1115                    (&self.content.fallback_lut_view, 0)
1116                }
1117            } else {
1118                (&self.content.fallback_lut_view, 0)
1119            };
1120        let uniform_data = RibbonUniform {
1121            model: item.model,
1122            colour: item.colour,
1123            radius: item.width * 0.5,
1124            use_vertex_colour,
1125            unlit: item.settings.unlit as u32,
1126            opacity: item.settings.opacity,
1127            wireframe: wireframe as u32,
1128            has_texture,
1129            _pad: [0.0; 2],
1130        };
1131        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
1132            label: Some("ribbon_uniform_buf"),
1133            size: std::mem::size_of::<RibbonUniform>() as u64,
1134            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1135            mapped_at_creation: false,
1136        });
1137        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
1138
1139        let bgl = self
1140            .ribbon
1141            .bgl
1142            .as_ref()
1143            .expect("ensure_streamtube_pipeline not called");
1144        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1145            label: Some("ribbon_uniform_bg"),
1146            layout: bgl,
1147            entries: &[
1148                wgpu::BindGroupEntry {
1149                    binding: 0,
1150                    resource: uniform_buf.as_entire_binding(),
1151                },
1152                wgpu::BindGroupEntry {
1153                    binding: 1,
1154                    resource: wgpu::BindingResource::TextureView(texture_view),
1155                },
1156                wgpu::BindGroupEntry {
1157                    binding: 2,
1158                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
1159                },
1160            ],
1161        });
1162
1163        StreamtubeGpuData {
1164            vertex_buffer,
1165            index_buffer,
1166            index_count,
1167            edge_index_buffer,
1168            edge_index_count,
1169            wireframe,
1170            uniform_bind_group,
1171            blend: item.blend,
1172            _uniform_buf: uniform_buf,
1173        }
1174    }
1175
1176    /// Pre-upload a ribbon and return a typed handle.
1177    pub fn upload_ribbon(
1178        &mut self,
1179        device: &wgpu::Device,
1180        queue: &wgpu::Queue,
1181        item: &crate::renderer::RibbonItem,
1182    ) -> crate::resources::RibbonId {
1183        self.ensure_streamtube_pipeline(device);
1184        let gpu = self.upload_ribbon_per_frame(device, queue, item, false);
1185        self.content.ribbon_store.insert(gpu)
1186    }
1187
1188    /// Remove a pre-uploaded ribbon.
1189    pub fn drop_ribbon(&mut self, id: crate::resources::RibbonId) -> bool {
1190        self.content.ribbon_store.remove(id)
1191    }
1192
1193    /// Replace the geometry of a pre-uploaded ribbon, keeping the same id.
1194    pub fn replace_ribbon(
1195        &mut self,
1196        device: &wgpu::Device,
1197        queue: &wgpu::Queue,
1198        id: crate::resources::RibbonId,
1199        item: &crate::renderer::RibbonItem,
1200    ) -> bool {
1201        if !self.content.ribbon_store.contains(id) {
1202            return false;
1203        }
1204        self.ensure_streamtube_pipeline(device);
1205        let gpu = self.upload_ribbon_per_frame(device, queue, item, false);
1206        self.content.ribbon_store.replace(id, gpu)
1207    }
1208
1209    /// Start an asynchronous streamtube upload.
1210    pub fn begin_upload_streamtube(
1211        &mut self,
1212        device: &wgpu::Device,
1213        queue: &wgpu::Queue,
1214        item: crate::renderer::StreamtubeItem,
1215    ) -> crate::resources::JobId {
1216        let slot = crate::resources::ResultSlot::<crate::resources::StreamtubeId>::new();
1217        let slot_for_apply = slot.clone();
1218        let device_for_apply = device.clone();
1219        let queue_for_apply = queue.clone();
1220        let id = {
1221            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
1222            runner.submit_cpu(move |progress| {
1223                progress.set(0.9);
1224                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
1225                    Box::new(move |resources: &mut DeviceResources| {
1226                        let sid =
1227                            resources.upload_streamtube(&device_for_apply, &queue_for_apply, &item);
1228                        slot_for_apply.set(sid);
1229                    }),
1230                ))
1231            })
1232        };
1233        self.job_results
1234            .streamtube
1235            .lock()
1236            .expect("streamtube result map poisoned")
1237            .insert(id, slot);
1238        id
1239    }
1240
1241    /// Take the [`StreamtubeId`](crate::resources::StreamtubeId) produced by a
1242    /// completed [`begin_upload_streamtube`](Self::begin_upload_streamtube) job.
1243    pub fn upload_result_streamtube(
1244        &mut self,
1245        id: crate::resources::JobId,
1246    ) -> crate::error::ViewportResult<crate::resources::StreamtubeId> {
1247        let mut map = self
1248            .job_results
1249            .streamtube
1250            .lock()
1251            .expect("streamtube result map poisoned");
1252        let slot = match map.get(&id) {
1253            Some(s) => s.clone(),
1254            None => {
1255                return Err(crate::error::ViewportError::JobResultMissing {
1256                    reason: "unknown id or wrong upload type",
1257                });
1258            }
1259        };
1260        match slot.take() {
1261            Some(sid) => {
1262                map.remove(&id);
1263                Ok(sid)
1264            }
1265            None => Err(crate::error::ViewportError::JobNotReady),
1266        }
1267    }
1268
1269    /// Start an asynchronous tube upload.
1270    pub fn begin_upload_tube(
1271        &mut self,
1272        device: &wgpu::Device,
1273        queue: &wgpu::Queue,
1274        item: crate::renderer::TubeItem,
1275    ) -> crate::resources::JobId {
1276        let slot = crate::resources::ResultSlot::<crate::resources::TubeId>::new();
1277        let slot_for_apply = slot.clone();
1278        let device_for_apply = device.clone();
1279        let queue_for_apply = queue.clone();
1280        let id = {
1281            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
1282            runner.submit_cpu(move |progress| {
1283                progress.set(0.9);
1284                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
1285                    Box::new(move |resources: &mut DeviceResources| {
1286                        let tid = resources.upload_tube(&device_for_apply, &queue_for_apply, &item);
1287                        slot_for_apply.set(tid);
1288                    }),
1289                ))
1290            })
1291        };
1292        self.job_results
1293            .tube
1294            .lock()
1295            .expect("tube result map poisoned")
1296            .insert(id, slot);
1297        id
1298    }
1299
1300    /// Take the [`TubeId`](crate::resources::TubeId) produced by a completed
1301    /// [`begin_upload_tube`](Self::begin_upload_tube) job.
1302    pub fn upload_result_tube(
1303        &mut self,
1304        id: crate::resources::JobId,
1305    ) -> crate::error::ViewportResult<crate::resources::TubeId> {
1306        let mut map = self
1307            .job_results
1308            .tube
1309            .lock()
1310            .expect("tube result map poisoned");
1311        let slot = match map.get(&id) {
1312            Some(s) => s.clone(),
1313            None => {
1314                return Err(crate::error::ViewportError::JobResultMissing {
1315                    reason: "unknown id or wrong upload type",
1316                });
1317            }
1318        };
1319        match slot.take() {
1320            Some(tid) => {
1321                map.remove(&id);
1322                Ok(tid)
1323            }
1324            None => Err(crate::error::ViewportError::JobNotReady),
1325        }
1326    }
1327
1328    /// Start an asynchronous ribbon upload.
1329    pub fn begin_upload_ribbon(
1330        &mut self,
1331        device: &wgpu::Device,
1332        queue: &wgpu::Queue,
1333        item: crate::renderer::RibbonItem,
1334    ) -> crate::resources::JobId {
1335        let slot = crate::resources::ResultSlot::<crate::resources::RibbonId>::new();
1336        let slot_for_apply = slot.clone();
1337        let device_for_apply = device.clone();
1338        let queue_for_apply = queue.clone();
1339        let id = {
1340            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
1341            runner.submit_cpu(move |progress| {
1342                progress.set(0.9);
1343                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
1344                    Box::new(move |resources: &mut DeviceResources| {
1345                        let rid =
1346                            resources.upload_ribbon(&device_for_apply, &queue_for_apply, &item);
1347                        slot_for_apply.set(rid);
1348                    }),
1349                ))
1350            })
1351        };
1352        self.job_results
1353            .ribbon
1354            .lock()
1355            .expect("ribbon result map poisoned")
1356            .insert(id, slot);
1357        id
1358    }
1359
1360    /// Take the [`RibbonId`](crate::resources::RibbonId) produced by a completed
1361    /// [`begin_upload_ribbon`](Self::begin_upload_ribbon) job.
1362    pub fn upload_result_ribbon(
1363        &mut self,
1364        id: crate::resources::JobId,
1365    ) -> crate::error::ViewportResult<crate::resources::RibbonId> {
1366        let mut map = self
1367            .job_results
1368            .ribbon
1369            .lock()
1370            .expect("ribbon result map poisoned");
1371        let slot = match map.get(&id) {
1372            Some(s) => s.clone(),
1373            None => {
1374                return Err(crate::error::ViewportError::JobResultMissing {
1375                    reason: "unknown id or wrong upload type",
1376                });
1377            }
1378        };
1379        match slot.take() {
1380            Some(rid) => {
1381                map.remove(&id);
1382                Ok(rid)
1383            }
1384            None => Err(crate::error::ViewportError::JobNotReady),
1385        }
1386    }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391    use crate::DeviceResources;
1392    use crate::renderer::{RibbonItem, StreamtubeItem, TubeItem};
1393    use crate::resources::UploadStatus;
1394
1395    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
1396        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
1397        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1398            power_preference: wgpu::PowerPreference::LowPower,
1399            compatible_surface: None,
1400            force_fallback_adapter: false,
1401        }))
1402        .ok()?;
1403        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
1404    }
1405
1406    fn sample_streamtube() -> StreamtubeItem {
1407        StreamtubeItem {
1408            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
1409            strip_lengths: vec![3],
1410            radius: 0.1,
1411            ..Default::default()
1412        }
1413    }
1414
1415    fn sample_tube() -> TubeItem {
1416        TubeItem {
1417            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
1418            strip_lengths: vec![3],
1419            radius: 0.1,
1420            ..Default::default()
1421        }
1422    }
1423
1424    fn sample_ribbon() -> RibbonItem {
1425        RibbonItem {
1426            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
1427            strip_lengths: vec![3],
1428            width: 0.2,
1429            ..Default::default()
1430        }
1431    }
1432
1433    fn drive_until_ready(
1434        resources: &mut DeviceResources,
1435        device: &wgpu::Device,
1436        queue: &wgpu::Queue,
1437        id: crate::resources::JobId,
1438        label: &'static str,
1439    ) {
1440        for _ in 0..200 {
1441            resources.process_uploads(device, queue);
1442            match resources.upload_status(id) {
1443                UploadStatus::Ready => return,
1444                UploadStatus::Failed(e) => panic!("{label} upload failed: {e:?}"),
1445                UploadStatus::Pending { .. } => {
1446                    std::thread::sleep(std::time::Duration::from_millis(5));
1447                }
1448                UploadStatus::Unknown => panic!("{label} job id disappeared"),
1449            }
1450        }
1451        panic!("{label} upload did not complete in time");
1452    }
1453
1454    const IDENTITY: [[f32; 4]; 4] = [
1455        [1.0, 0.0, 0.0, 0.0],
1456        [0.0, 1.0, 0.0, 0.0],
1457        [0.0, 0.0, 1.0, 0.0],
1458        [0.0, 0.0, 0.0, 1.0],
1459    ];
1460
1461    #[test]
1462    fn streamtube_default_model_is_identity() {
1463        assert_eq!(StreamtubeItem::default().model, IDENTITY);
1464    }
1465
1466    #[test]
1467    fn tube_default_model_is_identity() {
1468        assert_eq!(TubeItem::default().model, IDENTITY);
1469    }
1470
1471    #[test]
1472    fn ribbon_default_model_is_identity() {
1473        assert_eq!(RibbonItem::default().model, IDENTITY);
1474    }
1475
1476    #[test]
1477    fn streamtube_carries_non_identity_model() {
1478        let mut m = IDENTITY;
1479        m[3] = [1.0, 2.0, 3.0, 1.0];
1480        let item = StreamtubeItem {
1481            model: m,
1482            ..StreamtubeItem::default()
1483        };
1484        assert_eq!(item.model[3], [1.0, 2.0, 3.0, 1.0]);
1485    }
1486
1487    #[test]
1488    fn tube_carries_non_identity_model() {
1489        let mut m = IDENTITY;
1490        m[3] = [1.0, 2.0, 3.0, 1.0];
1491        let item = TubeItem {
1492            model: m,
1493            ..TubeItem::default()
1494        };
1495        assert_eq!(item.model[3], [1.0, 2.0, 3.0, 1.0]);
1496    }
1497
1498    #[test]
1499    fn ribbon_carries_non_identity_model() {
1500        let mut m = IDENTITY;
1501        m[3] = [1.0, 2.0, 3.0, 1.0];
1502        let item = RibbonItem {
1503            model: m,
1504            ..RibbonItem::default()
1505        };
1506        assert_eq!(item.model[3], [1.0, 2.0, 3.0, 1.0]);
1507    }
1508
1509    #[test]
1510    fn upload_streamtube_returns_valid_handle() {
1511        let Some((device, queue)) = try_make_device() else {
1512            eprintln!("skipping: no wgpu adapter available");
1513            return;
1514        };
1515        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1516        let id = resources.upload_streamtube(&device, &queue, &sample_streamtube());
1517        assert!(resources.content.streamtube_store.contains(id));
1518        assert!(resources.drop_streamtube(id));
1519        assert!(!resources.content.streamtube_store.contains(id));
1520    }
1521
1522    #[test]
1523    fn upload_tube_returns_valid_handle() {
1524        let Some((device, queue)) = try_make_device() else {
1525            eprintln!("skipping: no wgpu adapter available");
1526            return;
1527        };
1528        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1529        let start = resources.resident_bytes().scivis_bytes;
1530        let id = resources.upload_tube(&device, &queue, &sample_tube());
1531        assert!(resources.content.tube_store.contains(id));
1532        let after_upload = resources.resident_bytes().scivis_bytes;
1533        assert!(
1534            after_upload > start,
1535            "uploading a tube must increase resident scivis bytes"
1536        );
1537        assert!(resources.drop_tube(id));
1538        assert_eq!(
1539            resources.resident_bytes().scivis_bytes,
1540            start,
1541            "dropping the tube must return resident scivis bytes to the start"
1542        );
1543    }
1544
1545    #[test]
1546    fn upload_ribbon_returns_valid_handle() {
1547        let Some((device, queue)) = try_make_device() else {
1548            eprintln!("skipping: no wgpu adapter available");
1549            return;
1550        };
1551        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1552        let id = resources.upload_ribbon(&device, &queue, &sample_ribbon());
1553        assert!(resources.content.ribbon_store.contains(id));
1554        assert!(resources.drop_ribbon(id));
1555    }
1556
1557    #[test]
1558    fn begin_upload_streamtube_drains_to_handle() {
1559        let Some((device, queue)) = try_make_device() else {
1560            eprintln!("skipping: no wgpu adapter available");
1561            return;
1562        };
1563        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1564        let job = resources.begin_upload_streamtube(&device, &queue, sample_streamtube());
1565        drive_until_ready(&mut resources, &device, &queue, job, "streamtube");
1566        let id = resources.upload_result_streamtube(job).expect("ready");
1567        assert!(resources.content.streamtube_store.contains(id));
1568        let err = resources.upload_result_streamtube(job).unwrap_err();
1569        assert!(matches!(
1570            err,
1571            crate::error::ViewportError::JobResultMissing { .. }
1572        ));
1573    }
1574
1575    #[test]
1576    fn begin_upload_tube_drains_to_handle() {
1577        let Some((device, queue)) = try_make_device() else {
1578            eprintln!("skipping: no wgpu adapter available");
1579            return;
1580        };
1581        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1582        let job = resources.begin_upload_tube(&device, &queue, sample_tube());
1583        drive_until_ready(&mut resources, &device, &queue, job, "tube");
1584        let id = resources.upload_result_tube(job).expect("ready");
1585        assert!(resources.content.tube_store.contains(id));
1586    }
1587
1588    #[test]
1589    fn begin_upload_ribbon_drains_to_handle() {
1590        let Some((device, queue)) = try_make_device() else {
1591            eprintln!("skipping: no wgpu adapter available");
1592            return;
1593        };
1594        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1595        let job = resources.begin_upload_ribbon(&device, &queue, sample_ribbon());
1596        drive_until_ready(&mut resources, &device, &queue, job, "ribbon");
1597        let id = resources.upload_result_ribbon(job).expect("ready");
1598        assert!(resources.content.ribbon_store.contains(id));
1599    }
1600}
1601
1602/// Per-frame GPU data for one streamtube item, created in `prepare()`.
1603///
1604/// The connected tube mesh (vertices + indices) is generated CPU-side for the
1605/// entire item (all strips) and uploaded as a single owned buffer pair.
1606#[derive(Clone)]
1607pub struct StreamtubeGpuData {
1608    /// Owned vertex buffer for the connected tube mesh (world-space positions + normals).
1609    pub(crate) vertex_buffer: wgpu::Buffer,
1610    /// Owned index buffer for the connected tube mesh (triangle indices).
1611    pub(crate) index_buffer: wgpu::Buffer,
1612    /// Number of triangle indices to draw (solid mode).
1613    pub(crate) index_count: u32,
1614    /// Owned index buffer for wireframe edges (deduplicated line-list pairs).
1615    pub(crate) edge_index_buffer: wgpu::Buffer,
1616    /// Number of edge indices to draw (wireframe mode).
1617    pub(crate) edge_index_count: u32,
1618    /// Whether this item should be drawn in wireframe mode.
1619    pub(crate) wireframe: bool,
1620    /// Bind group (group 1): tube uniform (colour, radius).
1621    pub(crate) uniform_bind_group: wgpu::BindGroup,
1622    /// Blend mode for the draw. Streamtubes always set this to
1623    /// `SpriteBlend::AlphaBlend`; ribbons honour the value from `RibbonItem`.
1624    pub(crate) blend: crate::renderer::SpriteBlend,
1625    // Keep uniform buffer alive.
1626    pub(crate) _uniform_buf: wgpu::Buffer,
1627}