Skip to main content

ferritin_bevy/
structure.rs

1//! Structure.
2//!
3//! Struct for rendering with Bevy
4//!
5//!
6
7use super::ColorScheme;
8use crate::selection::AtomMask;
9use bevy::asset::RenderAssetUsages;
10use bevy::math::Vec4;
11use bevy::prelude::{
12    Color, Component, Cylinder, Mesh, MeshBuilder, Meshable, Quat, Sphere, StandardMaterial,
13    Transform, Vec3, default,
14};
15use bevy::render::mesh::{Indices, PrimitiveTopology};
16use bon::Builder;
17use ferritin_core::Model;
18
19/// Enum representing various rendering options.
20///
21/// Each of these enums represents a rendering path that can be used by a `Structure`
22///
23/// Down the Line: allow passing an arbitrary function that maps PDB to mesh.
24///
25#[derive(Clone, PartialEq)]
26pub enum RenderOptions {
27    Wireframe,
28    Cartoon,
29    BallAndStick,
30    Solid,
31    Putty,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35enum SecondaryStructure {
36    Helix,
37    Sheet,
38    Loop,
39}
40
41// Structure to hold residue information needed for cartoon rendering
42struct BackboneAtoms {
43    ca: Vec3,
44    n: Vec3,
45    c: Vec3,
46}
47
48/// Define Everything Needed to render
49#[derive(Builder, Component)]
50pub struct Structure {
51    pdb: Model,
52    #[builder(default = RenderOptions::Solid)]
53    rendertype: RenderOptions,
54    #[builder(default = ColorScheme::Solid(Color::WHITE))]
55    color_scheme: ColorScheme,
56    #[builder(default = StandardMaterial::default())]
57    material: StandardMaterial,
58}
59
60impl Structure {
61    pub fn to_mesh(&self) -> Mesh {
62        match &self.rendertype {
63            RenderOptions::Wireframe => self.render_wireframe(),
64            RenderOptions::Cartoon => self.render_cartoon(),
65            RenderOptions::BallAndStick => self.render_ballandstick(),
66            RenderOptions::Solid => self.render_spheres(),
67            RenderOptions::Putty => self.render_putty(),
68        }
69    }
70
71    /// Returns `(Mesh, atom_map)` where `atom_map[vertex_idx]` is the source atom index.
72    /// Applicable to Solid, BallAndStick, and Wireframe render types.
73    /// `mask` filters which atoms contribute to the mesh; `None` includes all atoms.
74    pub fn to_mesh_with_atom_map(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
75        match &self.rendertype {
76            RenderOptions::Solid => self.render_spheres_mapped(mask),
77            RenderOptions::BallAndStick => self.render_ballandstick_mapped(mask),
78            RenderOptions::Wireframe => self.render_wireframe_mapped(mask),
79            _ => (self.to_mesh(), Vec::new()),
80        }
81    }
82
83    /// Returns `(Mesh, residue_map)` where `residue_map[vertex_idx]` is the 0-based residue
84    /// iteration index that produced that vertex. Applicable to Cartoon and Putty.
85    /// `mask` filters which residues contribute; `None` includes all residues.
86    pub fn to_mesh_with_residue_map(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
87        match &self.rendertype {
88            RenderOptions::Cartoon => self.render_cartoon_mapped(mask),
89            RenderOptions::Putty => self.render_putty_mapped(mask),
90            _ => (self.to_mesh(), Vec::new()),
91        }
92    }
93
94    /// Returns the material for this structure.
95    ///
96    /// Bevy 0.19 auto-enables vertex colors when the mesh contains ATTRIBUTE_COLOR,
97    /// so no material flag is needed here.
98    pub fn get_material(&self) -> StandardMaterial {
99        self.material.clone()
100    }
101
102    /// Compute the centroid (mean position) of all atoms.
103    pub fn centroid(&self) -> Vec3 {
104        let mut sum = Vec3::ZERO;
105        let mut count = 0usize;
106        for atom in self.pdb.atoms() {
107            sum += Vec3::from_array(atom.coords());
108            count += 1;
109        }
110        if count == 0 {
111            Vec3::ZERO
112        } else {
113            sum / count as f32
114        }
115    }
116
117    /// Maximum CA-CA distance (Å) treated as a continuous backbone. A trans
118    /// peptide bond gives ~3.8 Å; anything beyond this is a chain break or a
119    /// stretch of missing residues, across which the ribbon must not spline.
120    const CA_BREAK_DIST: f32 = 5.0;
121
122    /// BallAndStick geometry (Å). The stick is half the ball radius so the rep
123    /// reads as spheres joined by thin sticks rather than uniform fat tubes
124    /// (ferritin-c1k.1).
125    const BALL_RADIUS: f32 = 0.3;
126    const STICK_RADIUS: f32 = 0.15;
127
128    /// Wireframe/Line bond radius (Å). A GPU `LineList` rasterizes to a hard 1px
129    /// edge with no normals, which aliases badly on large structures and can't be
130    /// lit or anti-aliased. Rendering the Line representation as thin triangle
131    /// cylinders instead gives it real geometry (smooth-shaded, MSAA-covered)
132    /// while staying visually distinct from BallAndStick's thicker sticks
133    /// (ferritin-t0h.9).
134    const LINE_RADIUS: f32 = 0.13;
135
136    /// Tube tessellation density shared by Cartoon and Putty: vertices around
137    /// the circumference, and interpolated curve points per residue along the
138    /// length. The previous 16/4 (cartoon) and 16/3 (putty) values were coarse
139    /// enough that the tube read as an angular, low-poly prism at close zoom
140    /// (ferritin-c1k.3).
141    const TUBE_CROSS_SEGMENTS: usize = 20;
142    const TUBE_CURVE_SEGMENTS: usize = 6;
143
144    /// Residue names treated as solvent for de-emphasis in BallAndStick/Solid
145    /// (Spacefill) rendering. Isolated water molecules have no bonds, so at
146    /// full atomic radius they show up as a scattered field of disconnected
147    /// dots cluttering the periphery of the real structure. Mol* renders
148    /// solvent distinctly (small/dim) by default; here we shrink the sphere
149    /// radius so waters read as background rather than competing with the
150    /// structure (ferritin-t0h.7).
151    const WATER_RESIDUE_NAMES: [&'static str; 3] = ["HOH", "WAT", "H2O"];
152    const WATER_RADIUS_SCALE: f32 = 0.35;
153
154    fn is_water_residue(name: &str) -> bool {
155        Self::WATER_RESIDUE_NAMES.contains(&name)
156    }
157
158    /// Builds the two half-length cylinders for a ball-and-stick bond, split at
159    /// the bond's midpoint so the caller can color/map each half to its own
160    /// endpoint atom — real bonds in molecular viewers are two-toned, half per
161    /// atom, rather than the whole stick taking on a single atom's color
162    /// (ferritin-c1k.2). Returns `None` for a degenerate (zero-length) bond.
163    fn half_bond_cylinders(pos1: Vec3, pos2: Vec3, radius: f32) -> Option<(Mesh, Mesh)> {
164        let direction = pos2 - pos1;
165        let height = direction.length();
166        if height < 1e-6 {
167            return None;
168        }
169        let mid = (pos1 + pos2) * 0.5;
170        let rotation = Quat::from_rotation_arc(Vec3::Y, direction.normalize());
171        let half_height = height / 4.0;
172        let make_half = |center: Vec3| -> Mesh {
173            Cylinder {
174                radius,
175                half_height,
176            }
177            .mesh()
178            .build()
179            .transformed_by(Transform {
180                translation: center,
181                rotation,
182                ..default()
183            })
184        };
185        let first = make_half((pos1 + mid) * 0.5);
186        let second = make_half((mid + pos2) * 0.5);
187        Some((first, second))
188    }
189
190    /// Whether two consecutive backbone residues should start a new ribbon
191    /// segment rather than be splined together. A break is any of: a gap in the
192    /// (masked) residue-iteration index, a chain change, or a CA-CA distance
193    /// beyond a single peptide bond (missing residues / HETATM insertion).
194    /// See ferritin-t0h.1.
195    fn is_backbone_break(
196        prev_idx: usize,
197        prev_chain: &str,
198        prev_ca: Vec3,
199        idx: usize,
200        chain: &str,
201        ca: Vec3,
202    ) -> bool {
203        idx != prev_idx + 1 || chain != prev_chain || ca.distance(prev_ca) > Self::CA_BREAK_DIST
204    }
205
206    fn create_smooth_curve(points: &[Vec3], segments: usize) -> Vec<Vec3> {
207        let mut curve_points = Vec::new();
208
209        for i in 0..points.len() - 1 {
210            let p0 = if i == 0 { points[0] } else { points[i - 1] };
211            let p1 = points[i];
212            let p2 = points[i + 1];
213            let p3 = if i + 2 >= points.len() {
214                points[points.len() - 1]
215            } else {
216                points[i + 2]
217            };
218
219            for t in 0..segments {
220                let t = t as f32 / segments as f32;
221                let pos = Structure::catmull_rom(p0, p1, p2, p3, t);
222                curve_points.push(pos);
223            }
224        }
225
226        curve_points
227    }
228
229    /// Rotation-minimizing frames along a curve (parallel transport). Returns one
230    /// orthonormal `(right, up)` pair per curve point, both perpendicular to the
231    /// local tangent. The pair rotates *minimally* from one point to the next, so
232    /// the extruded tube cross-section never flips 180° at a kink the way the old
233    /// `tangent × global_Y` frame did — that flip produced the white starburst
234    /// spikes at joints (ferritin-t0h.2).
235    fn sweep_frames(curve: &[Vec3]) -> Vec<(Vec3, Vec3)> {
236        let n = curve.len();
237        let mut frames = Vec::with_capacity(n);
238        if n == 0 {
239            return frames;
240        }
241
242        let tangent = |i: usize| -> Vec3 {
243            let raw = if i + 1 < n {
244                curve[i + 1] - curve[i]
245            } else {
246                curve[i] - curve[i - 1]
247            };
248            raw.normalize_or_zero()
249        };
250
251        let t0 = {
252            let t = tangent(0);
253            if t.length_squared() > 1e-8 {
254                t
255            } else {
256                Vec3::Z
257            }
258        };
259        let seed = if t0.abs_diff_eq(Vec3::Y, 0.01) {
260            Vec3::X
261        } else {
262            Vec3::Y
263        };
264        let mut right = t0.cross(seed).normalize();
265        let mut up = t0.cross(right).normalize();
266        frames.push((right, up));
267
268        for i in 1..n {
269            let t_prev = tangent(i - 1);
270            let t_cur = tangent(i);
271            let axis = t_prev.cross(t_cur);
272            let sin_a = axis.length();
273            if sin_a > 1e-6 {
274                let axis_n = axis / sin_a;
275                let cos_a = t_prev.dot(t_cur).clamp(-1.0, 1.0);
276                let angle = sin_a.atan2(cos_a);
277                right = Quat::from_axis_angle(axis_n, angle) * right;
278            }
279            // Re-orthonormalize against the current tangent to kill drift.
280            right = (right - t_cur * right.dot(t_cur)).normalize_or_zero();
281            if right.length_squared() < 1e-8 {
282                let fallback = if t_cur.abs_diff_eq(Vec3::Y, 0.01) {
283                    Vec3::X
284                } else {
285                    Vec3::Y
286                };
287                right = t_cur.cross(fallback).normalize();
288            }
289            up = t_cur.cross(right).normalize();
290            frames.push((right, up));
291        }
292
293        frames
294    }
295
296    /// Per-residue reference directions for orienting the cartoon ribbon's flat
297    /// cross-section, using the Carson-Bugg "virtual torsion" construction:
298    /// normal(i) = normalize((CA(i) - CA(i-1)) x (CA(i+1) - CA(i))). Unlike a
299    /// pure parallel-transported frame (whose orientation is arbitrary — seeded
300    /// from a global axis with no relation to the actual backbone geometry),
301    /// this direction is derived from the real CA trace, so consecutive
302    /// residues' flat faces consistently track the backbone's own twist instead
303    /// of an arbitrary seed (ferritin-t0h.3).
304    ///
305    /// This construction flips sign roughly every residue in extended (beta)
306    /// conformations because of the backbone's zigzag; consecutive directions
307    /// are sign-corrected (flipped if their dot product is negative) so the
308    /// ribbon doesn't whip 180° every residue. Endpoints reuse their nearest
309    /// interior neighbor. One direction per input CA position.
310    fn peptide_plane_normals(ca_positions: &[Vec3]) -> Vec<Vec3> {
311        let n = ca_positions.len();
312        let mut normals = vec![Vec3::Y; n];
313        if n < 3 {
314            return normals;
315        }
316        for i in 1..n - 1 {
317            let b1 = ca_positions[i] - ca_positions[i - 1];
318            let b2 = ca_positions[i + 1] - ca_positions[i];
319            let normal = b1.cross(b2).normalize_or_zero();
320            normals[i] = if normal.length_squared() > 1e-8 {
321                normal
322            } else {
323                Vec3::Y
324            };
325        }
326        normals[0] = normals[1];
327        normals[n - 1] = normals[n - 2];
328        for i in 1..n {
329            if normals[i].dot(normals[i - 1]) < 0.0 {
330                normals[i] = -normals[i];
331            }
332        }
333        normals
334    }
335
336    /// Normalized linear interpolation between two directions; falls back to `a`
337    /// if the blend degenerates (e.g. `a` and `b` nearly cancel).
338    fn nlerp_direction(a: Vec3, b: Vec3, t: f32) -> Vec3 {
339        let blended = a * (1.0 - t) + b * t;
340        let n = blended.normalize_or_zero();
341        if n.length_squared() > 1e-8 { n } else { a }
342    }
343
344    /// Like [`sweep_frames`], but steers each ring's `up` vector toward an
345    /// explicit per-point hint (projected perpendicular to the local tangent)
346    /// instead of purely parallel-transporting an arbitrary seed. Falls back to
347    /// parallel transport from the previous frame when the hint is degenerate
348    /// (nearly parallel to the tangent), and sign-corrects against the previous
349    /// frame so the ribbon doesn't flip 180° between rings. Used to orient the
350    /// cartoon ribbon with the real backbone geometry (ferritin-t0h.3) rather
351    /// than an arbitrary global-axis seed.
352    fn sweep_frames_oriented(curve: &[Vec3], target_up: &[Vec3]) -> Vec<(Vec3, Vec3)> {
353        let n = curve.len();
354        let mut frames = Vec::with_capacity(n);
355        if n == 0 {
356            return frames;
357        }
358
359        let tangent = |i: usize| -> Vec3 {
360            let raw = if i + 1 < n {
361                curve[i + 1] - curve[i]
362            } else {
363                curve[i] - curve[i - 1]
364            };
365            let t = raw.normalize_or_zero();
366            if t.length_squared() > 1e-8 {
367                t
368            } else {
369                Vec3::Z
370            }
371        };
372
373        let mut prev_up: Option<Vec3> = None;
374        for i in 0..n {
375            let t_cur = tangent(i);
376            let hint = target_up.get(i).copied().unwrap_or(Vec3::Y);
377            let mut up = (hint - t_cur * hint.dot(t_cur)).normalize_or_zero();
378            if up.length_squared() < 1e-8 {
379                // Hint nearly parallel to the tangent (degenerate projection):
380                // fall back to parallel-transporting the previous frame's up.
381                up = match prev_up {
382                    Some(p) => (p - t_cur * p.dot(t_cur)).normalize_or_zero(),
383                    None => {
384                        let seed = if t_cur.abs_diff_eq(Vec3::Y, 0.01) {
385                            Vec3::X
386                        } else {
387                            Vec3::Y
388                        };
389                        t_cur.cross(seed).normalize_or_zero()
390                    }
391                };
392            }
393            if let Some(p) = prev_up
394                && up.dot(p) < 0.0
395            {
396                up = -up;
397            }
398            let right = up.cross(t_cur).normalize_or_zero();
399            // Re-derive up from (tangent, right) so the pair is exactly
400            // orthonormal (matches the `up = tangent x right` convention used
401            // by the mesh-generation cross-sections).
402            let up = t_cur.cross(right).normalize_or_zero();
403            frames.push((right, up));
404            prev_up = Some(up);
405        }
406
407        frames
408    }
409
410    /// Catmull-Rom spline interpolation
411    fn catmull_rom(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: f32) -> Vec3 {
412        let t2 = t * t;
413        let t3 = t2 * t;
414        let v0 = (p2 - p0) * 0.5;
415        let v1 = (p3 - p1) * 0.5;
416
417        (2.0 * p1 - 2.0 * p2 + v0 + v1) * t3
418            + (-3.0 * p1 + 3.0 * p2 - 2.0 * v0 - v1) * t2
419            + v0 * t
420            + p1
421    }
422
423    /// Generate a mesh around a curve path, with one radius per curve point (used
424    /// by Putty's synthetic thickness placeholder — see `synthetic_putty_radii`).
425    ///
426    /// # Panics
427    /// Panics if `radii.len() != curve.len()`.
428    fn generate_tube_mesh_varying(curve: &[Vec3], radii: &[f32], segments: usize) -> Mesh {
429        assert_eq!(radii.len(), curve.len(), "one radius per curve point");
430        let mut positions = Vec::new();
431        let mut normals = Vec::new();
432        let mut uvs = Vec::new();
433        let mut indices = Vec::new();
434        // Rotation-minimizing frames avoid cross-section flips at kinks (ferritin-t0h.2).
435        let frames = Structure::sweep_frames(curve);
436        // Generate circles around each point
437        for (i, &center) in curve.iter().enumerate() {
438            let radius = radii[i];
439            let (right, up) = frames[i];
440            // Create vertices around the circle
441            for j in 0..segments {
442                let angle = (j as f32 / segments as f32) * std::f32::consts::TAU;
443                let x = angle.cos();
444                let y = angle.sin();
445                let pos = center + (right * x + up * y) * radius;
446                let normal = (pos - center).normalize();
447                positions.push([pos.x, pos.y, pos.z]);
448                normals.push([normal.x, normal.y, normal.z]);
449                uvs.push([
450                    i as f32 / (curve.len() - 1) as f32,
451                    j as f32 / segments as f32,
452                ]);
453            }
454        }
455        // Generate indices for triangles
456        for i in 0..curve.len() - 1 {
457            for j in 0..segments {
458                let next_j = (j + 1) % segments;
459                let current_ring = i * segments;
460                let next_ring = (i + 1) * segments;
461                indices.push(current_ring + j);
462                indices.push(next_ring + j);
463                indices.push(current_ring + next_j);
464                indices.push(current_ring + next_j);
465                indices.push(next_ring + j);
466                indices.push(next_ring + next_j);
467            }
468        }
469
470        let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all());
471        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
472        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
473        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
474        mesh.insert_indices(Indices::U32(indices.iter().map(|&i| i as u32).collect()));
475        mesh
476    }
477
478    /// Torsion angle (radians) between the planes defined by four sequential atoms.
479    /// Uses the atan2 formulation so the result is in (-π, π].
480    fn dihedral(p1: Vec3, p2: Vec3, p3: Vec3, p4: Vec3) -> f32 {
481        let b1 = p2 - p1;
482        let b2 = p3 - p2;
483        let b3 = p4 - p3;
484        let n1 = b1.cross(b2);
485        let n2 = b2.cross(b3);
486        let m1 = n1.cross(b2.normalize());
487        f32::atan2(m1.dot(n2), n1.dot(n2))
488    }
489
490    /// Secondary structure classification via backbone phi/psi dihedral angles.
491    ///
492    /// Phi(i)  = dihedral( C(i-1), N(i),  CA(i), C(i)  )
493    /// Psi(i)  = dihedral( N(i),   CA(i), C(i),  N(i+1) )
494    ///
495    /// Thresholds (degrees, converted to radians internally):
496    ///   Helix: phi ∈ [-90, -30], psi ∈ [-80, 0]
497    ///   Sheet: phi ∈ [-170, -50], psi ∈ [60, 180] or psi ∈ [-180, -150]
498    fn detect_secondary_structure(atoms: &[BackboneAtoms]) -> Vec<SecondaryStructure> {
499        let n = atoms.len();
500        let mut sec = vec![SecondaryStructure::Loop; n];
501
502        let to_deg = |r: f32| r.to_degrees();
503
504        for i in 1..n.saturating_sub(1) {
505            let phi = Self::dihedral(atoms[i - 1].c, atoms[i].n, atoms[i].ca, atoms[i].c);
506            let psi = Self::dihedral(atoms[i].n, atoms[i].ca, atoms[i].c, atoms[i + 1].n);
507            let phi_d = to_deg(phi);
508            let psi_d = to_deg(psi);
509
510            if (-90.0..=-30.0).contains(&phi_d) && (-80.0..=0.0).contains(&psi_d) {
511                sec[i] = SecondaryStructure::Helix;
512            } else if (-170.0..=-50.0).contains(&phi_d) && (psi_d >= 60.0 || psi_d <= -150.0) {
513                sec[i] = SecondaryStructure::Sheet;
514            }
515        }
516
517        // Smooth out single-residue islands
518        let orig = sec.clone();
519        for i in 1..n - 1 {
520            if orig[i - 1] == orig[i + 1] && orig[i] != orig[i - 1] {
521                sec[i] = orig[i - 1];
522            }
523        }
524
525        sec
526    }
527
528    /// Generate a cartoon ribbon mesh from a smooth CA-trace curve, per-point
529    /// secondary structure assignments, and per-point ribbon-orientation hints.
530    ///
531    /// Cross-sections: helix → circle r=1.2, sheet → ellipse 2.0×0.5, loop → circle r=0.4.
532    /// Vertex colors: helix=salmon, sheet=periwinkle, loop=light-green.
533    fn generate_cartoon_mesh(
534        curve: &[Vec3],
535        sec_structures: &[SecondaryStructure],
536        target_up: &[Vec3],
537    ) -> Mesh {
538        let mut positions = Vec::new();
539        let mut normals = Vec::new();
540        let mut uvs = Vec::new();
541        let mut colors: Vec<Vec4> = Vec::new();
542        let mut indices = Vec::new();
543
544        // Control how many segments around the tube
545        let segments = Self::TUBE_CROSS_SEGMENTS;
546
547        // Orient the ribbon with the real backbone geometry (ferritin-t0h.3)
548        // instead of an arbitrary parallel-transported seed, while still
549        // avoiding cross-section flips at kinks (ferritin-t0h.2).
550        let frames = Structure::sweep_frames_oriented(curve, target_up);
551
552        // Generate cross-section profiles for each point in the curve
553        for (i, &center) in curve.iter().enumerate() {
554            let sec_type = if i < sec_structures.len() {
555                sec_structures[i]
556            } else {
557                SecondaryStructure::Loop
558            };
559
560            let (right, up) = frames[i];
561
562            let color = match sec_type {
563                SecondaryStructure::Helix => Vec4::new(1.0, 0.6, 0.6, 1.0),
564                SecondaryStructure::Sheet => Vec4::new(0.6, 0.6, 1.0, 1.0),
565                SecondaryStructure::Loop => Vec4::new(0.6, 0.9, 0.6, 1.0),
566            };
567
568            // Determine tube profile based on secondary structure
569            for j in 0..segments {
570                let angle = (j as f32 / segments as f32) * std::f32::consts::TAU;
571                let (x, y) = match sec_type {
572                    SecondaryStructure::Helix => {
573                        let radius = 1.2;
574                        (angle.cos() * radius, angle.sin() * radius)
575                    }
576                    SecondaryStructure::Sheet => {
577                        // Flattened ribbon profile
578                        (angle.cos() * 2.0, angle.sin() * 0.5)
579                    }
580                    SecondaryStructure::Loop => {
581                        let radius = 0.4;
582                        (angle.cos() * radius, angle.sin() * radius)
583                    }
584                };
585
586                let pos = center + (right * x + up * y);
587                let normal = (pos - center).normalize();
588
589                positions.push([pos.x, pos.y, pos.z]);
590                normals.push([normal.x, normal.y, normal.z]);
591                uvs.push([i as f32 / curve.len() as f32, j as f32 / segments as f32]);
592                colors.push(color);
593            }
594        }
595
596        // Generate triangle indices
597        for i in 0..curve.len() - 1 {
598            for j in 0..segments {
599                let next_j = (j + 1) % segments;
600                let current_ring = i * segments;
601                let next_ring = (i + 1) * segments;
602
603                indices.push(current_ring + j);
604                indices.push(next_ring + j);
605                indices.push(current_ring + next_j);
606
607                indices.push(current_ring + next_j);
608                indices.push(next_ring + j);
609                indices.push(next_ring + next_j);
610            }
611        }
612
613        let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all());
614        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
615        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
616        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
617        mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
618        mesh.insert_indices(Indices::U32(indices.iter().map(|&i| i as u32).collect()));
619
620        mesh
621    }
622
623    fn render_wireframe(&self) -> Mesh {
624        // Collect (chain_id, ca_position) for all amino-acid residues, then walk
625        // consecutive pairs emitting a thin cylinder segment for each edge whose
626        // endpoints share a chain (for a chain A→B→C→D: [A,B], [B,C], [C,D]).
627        let ca_by_chain: Vec<(String, Vec3)> = self
628            .pdb
629            .residues_aminoacid()
630            .filter_map(|residue| {
631                residue.find_atom_by_name("CA").map(|ca| {
632                    (
633                        residue.chain_id().to_string(),
634                        Vec3::from_array(ca.coords()),
635                    )
636                })
637            })
638            .collect();
639
640        let mut combined: Option<Mesh> = None;
641        for window in ca_by_chain.windows(2) {
642            let (chain_a, pos_a) = &window[0];
643            let (chain_b, pos_b) = &window[1];
644            if chain_a == chain_b
645                && let Some((mut first, second)) =
646                    Structure::half_bond_cylinders(*pos_a, *pos_b, Structure::LINE_RADIUS)
647            {
648                let _ = first.merge(&second);
649                match &mut combined {
650                    None => combined = Some(first),
651                    Some(acc) => {
652                        let _ = acc.merge(&first);
653                    }
654                }
655            }
656        }
657
658        combined
659            .unwrap_or_else(|| Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all()))
660    }
661
662    fn render_cartoon(&self) -> Mesh {
663        // Delegate to the mapped path (mask = None) so the ribbon is split into
664        // contiguous backbone segments at chain breaks / missing-residue gaps
665        // rather than splining straight through them (ferritin-t0h.1).
666        self.render_cartoon_mapped(None).0
667    }
668
669    fn render_ballandstick(&self) -> Mesh {
670        let mut combined_mesh = self
671            .pdb
672            .atoms()
673            .map(|atom| {
674                let coord = atom.coords();
675                let center = Vec3::new(coord[0], coord[1], coord[2]);
676                let element = atom.element();
677                let radius = if Self::is_water_residue(atom.residue_name()) {
678                    Structure::BALL_RADIUS * Structure::WATER_RADIUS_SCALE
679                } else {
680                    Structure::BALL_RADIUS
681                };
682                let mut sphere_mesh = Sphere::new(radius).mesh().build();
683                let vertex_count = sphere_mesh.count_vertices();
684                let color = self.color_scheme.get_color(&element).to_srgba();
685                let color_array =
686                    vec![Vec4::new(color.red, color.green, color.blue, color.alpha); vertex_count];
687                sphere_mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, color_array);
688                sphere_mesh = sphere_mesh.translated_by(center);
689                sphere_mesh.compute_smooth_normals();
690                sphere_mesh
691            })
692            .reduce(|mut acc, mesh| {
693                let _ = acc.merge(&mesh);
694                acc
695            })
696            .unwrap();
697
698        // Add bond cylinders, split at the midpoint and colored per endpoint
699        // atom so a bond visibly transitions between its two atoms' element
700        // colors instead of taking on a single flat color (ferritin-c1k.2).
701        let elements: Vec<ferritin_core::info::elements::Element> =
702            self.pdb.atoms().map(|a| a.element()).collect();
703        let bonds = &self.pdb.hierarchy.bonds;
704        if !bonds.atom_a.is_empty() {
705            (0..bonds.atom_a.len())
706                .filter_map(|i| {
707                    let idx_a = bonds.atom_a[i] as usize;
708                    let idx_b = bonds.atom_b[i] as usize;
709                    let pos1 = Vec3::from_array(self.pdb.coord(idx_a));
710                    let pos2 = Vec3::from_array(self.pdb.coord(idx_b));
711                    let (mut first, mut second) =
712                        Structure::half_bond_cylinders(pos1, pos2, Structure::STICK_RADIUS)?;
713
714                    let color_a = self.color_scheme.get_color(&elements[idx_a]).to_srgba();
715                    let vc1 = first.count_vertices();
716                    first.insert_attribute(
717                        Mesh::ATTRIBUTE_COLOR,
718                        vec![
719                            Vec4::new(color_a.red, color_a.green, color_a.blue, color_a.alpha);
720                            vc1
721                        ],
722                    );
723
724                    let color_b = self.color_scheme.get_color(&elements[idx_b]).to_srgba();
725                    let vc2 = second.count_vertices();
726                    second.insert_attribute(
727                        Mesh::ATTRIBUTE_COLOR,
728                        vec![
729                            Vec4::new(color_b.red, color_b.green, color_b.blue, color_b.alpha);
730                            vc2
731                        ],
732                    );
733
734                    let _ = first.merge(&second);
735                    Some(first)
736                })
737                .for_each(|bond_mesh| {
738                    let _ = combined_mesh.merge(&bond_mesh);
739                });
740        }
741        combined_mesh
742    }
743
744    fn render_spheres(&self) -> Mesh {
745        self.pdb
746            .atoms()
747            .map(|atom| {
748                let coord = atom.coords();
749                let center = Vec3::new(coord[0], coord[1], coord[2]);
750                let element = atom.element();
751                let mut radius = element
752                    .atomic_radius()
753                    .van_der_waals
754                    .expect("Van der waals not defined") as f32;
755                if Self::is_water_residue(atom.residue_name()) {
756                    radius *= Structure::WATER_RADIUS_SCALE;
757                }
758                let mut sphere_mesh = Sphere::new(radius).mesh().build();
759                let vertex_count = sphere_mesh.count_vertices();
760                let color = self.color_scheme.get_color(&element).to_srgba();
761                let color_array =
762                    vec![Vec4::new(color.red, color.green, color.blue, color.alpha); vertex_count];
763                sphere_mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, color_array);
764                sphere_mesh = sphere_mesh.translated_by(center);
765                sphere_mesh.compute_smooth_normals();
766                sphere_mesh
767            })
768            .reduce(|mut acc, mesh| {
769                let _ = acc.merge(&mesh);
770                acc
771            })
772            .unwrap()
773    }
774
775    fn render_putty(&self) -> Mesh {
776        // Delegate to the mapped path (mask = None) so the tube is split at chain
777        // breaks / missing-residue gaps rather than splining through them
778        // (ferritin-t0h.1).
779        self.render_putty_mapped(None).0
780    }
781
782    /// Placeholder tube-radius profile for Putty, until per-atom b_factor is
783    /// plumbed through from Model into AtomCollection (tracked in ferritin-clv).
784    /// Without it, Putty rendered as a constant-radius tube identical to
785    /// Cartoon's loop tube (ferritin-ala.10). A smooth sinusoidal variation along
786    /// the chain at least signals "this thickness carries data" rather than
787    /// looking like an unstyled Cartoon; it is not meaningful per-residue data.
788    fn synthetic_putty_radii(n: usize) -> Vec<f32> {
789        const BASE_RADIUS: f32 = 0.3;
790        const AMPLITUDE: f32 = 0.22;
791        const CYCLES: f32 = 4.0;
792        (0..n)
793            .map(|i| {
794                let t = i as f32 / n.max(1) as f32;
795                BASE_RADIUS + AMPLITUDE * (t * std::f32::consts::TAU * CYCLES).sin()
796            })
797            .collect()
798    }
799
800    // -----------------------------------------------------------------------
801    // Masked / mapped variants
802    // -----------------------------------------------------------------------
803
804    fn render_spheres_mapped(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
805        let mut atom_map: Vec<usize> = Vec::new();
806        let mut combined: Option<Mesh> = None;
807
808        for atom in self.pdb.atoms() {
809            let idx = atom.index();
810            if let Some(m) = mask
811                && !m.0[idx]
812            {
813                continue;
814            }
815
816            let coord = atom.coords();
817            let center = Vec3::new(coord[0], coord[1], coord[2]);
818            let element = atom.element();
819            let mut radius = element.atomic_radius().van_der_waals.expect("vdw radius") as f32;
820            if Self::is_water_residue(atom.residue_name()) {
821                radius *= Structure::WATER_RADIUS_SCALE;
822            }
823            let mut sphere_mesh = Sphere::new(radius).mesh().build();
824            let vc = sphere_mesh.count_vertices();
825            let color = self.color_scheme.get_color(&element).to_srgba();
826            sphere_mesh.insert_attribute(
827                Mesh::ATTRIBUTE_COLOR,
828                vec![Vec4::new(color.red, color.green, color.blue, color.alpha); vc],
829            );
830            sphere_mesh = sphere_mesh.translated_by(center);
831            sphere_mesh.compute_smooth_normals();
832            atom_map.extend(std::iter::repeat_n(idx, vc));
833            match &mut combined {
834                None => combined = Some(sphere_mesh),
835                Some(acc) => {
836                    let _ = acc.merge(&sphere_mesh);
837                }
838            }
839        }
840
841        let mesh = combined.unwrap_or_else(|| {
842            Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all())
843        });
844        (mesh, atom_map)
845    }
846
847    fn render_wireframe_mapped(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
848        let mut atom_map: Vec<usize> = Vec::new();
849        let mut combined: Option<Mesh> = None;
850
851        // Collect all CA atoms (retain ALL so window() neighbours stay correct).
852        let ca_data: Vec<(String, usize, Vec3)> = self
853            .pdb
854            .residues_aminoacid()
855            .filter_map(|residue| {
856                residue.find_atom_by_name("CA").map(|ca| {
857                    let idx = ca.index();
858                    (
859                        residue.chain_id().to_string(),
860                        idx,
861                        Vec3::from_array(ca.coords()),
862                    )
863                })
864            })
865            .collect();
866
867        // Emit an edge only when same chain AND both CA atoms pass the mask. Each
868        // edge is a pair of half-length cylinders, one per endpoint atom, so the
869        // atom_map (and downstream per-vertex coloring) stays split at the
870        // midpoint exactly like BallAndStick bonds (ferritin-c1k.2).
871        for window in ca_data.windows(2) {
872            let (chain_a, idx_a, pos_a) = &window[0];
873            let (chain_b, idx_b, pos_b) = &window[1];
874            let a_ok = mask.is_none_or(|m| m.0[*idx_a]);
875            let b_ok = mask.is_none_or(|m| m.0[*idx_b]);
876            if chain_a == chain_b
877                && a_ok
878                && b_ok
879                && let Some((first, second)) =
880                    Structure::half_bond_cylinders(*pos_a, *pos_b, Structure::LINE_RADIUS)
881            {
882                atom_map.extend(std::iter::repeat_n(*idx_a, first.count_vertices()));
883                atom_map.extend(std::iter::repeat_n(*idx_b, second.count_vertices()));
884                let mut seg = first;
885                let _ = seg.merge(&second);
886                match &mut combined {
887                    None => combined = Some(seg),
888                    Some(acc) => {
889                        let _ = acc.merge(&seg);
890                    }
891                }
892            }
893        }
894
895        let mesh = combined.unwrap_or_else(|| {
896            Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all())
897        });
898        (mesh, atom_map)
899    }
900
901    fn render_ballandstick_mapped(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
902        let mut atom_map: Vec<usize> = Vec::new();
903        let mut combined: Option<Mesh> = None;
904
905        // Atom spheres (filtered by mask).
906        for atom in self.pdb.atoms() {
907            let idx = atom.index();
908            if let Some(m) = mask
909                && !m.0[idx]
910            {
911                continue;
912            }
913
914            let coord = atom.coords();
915            let center = Vec3::new(coord[0], coord[1], coord[2]);
916            let element = atom.element();
917            let radius = if Self::is_water_residue(atom.residue_name()) {
918                Structure::BALL_RADIUS * Structure::WATER_RADIUS_SCALE
919            } else {
920                Structure::BALL_RADIUS
921            };
922            let mut sphere_mesh = Sphere::new(radius).mesh().build();
923            let vc = sphere_mesh.count_vertices();
924            let color = self.color_scheme.get_color(&element).to_srgba();
925            sphere_mesh.insert_attribute(
926                Mesh::ATTRIBUTE_COLOR,
927                vec![Vec4::new(color.red, color.green, color.blue, color.alpha); vc],
928            );
929            sphere_mesh = sphere_mesh.translated_by(center);
930            sphere_mesh.compute_smooth_normals();
931            atom_map.extend(std::iter::repeat_n(idx, vc));
932            match &mut combined {
933                None => combined = Some(sphere_mesh),
934                Some(acc) => {
935                    let _ = acc.merge(&sphere_mesh);
936                }
937            }
938        }
939
940        // Bond cylinders: skip if either endpoint is masked out. Split at the
941        // midpoint so each half maps to its own endpoint atom — the MVS color
942        // theme recolors every vertex by its atom_map entry, so without this
943        // split the whole stick took on a single atom's color instead of
944        // visibly transitioning between the two (ferritin-c1k.2).
945        let bonds = &self.pdb.hierarchy.bonds;
946        if !bonds.atom_a.is_empty() {
947            for i in 0..bonds.atom_a.len() {
948                let idx_a = bonds.atom_a[i] as usize;
949                let idx_b = bonds.atom_b[i] as usize;
950                if let Some(m) = mask
951                    && (!m.0[idx_a] || !m.0[idx_b])
952                {
953                    continue;
954                }
955                let pos1 = Vec3::from_array(self.pdb.coord(idx_a));
956                let pos2 = Vec3::from_array(self.pdb.coord(idx_b));
957                let Some((mut first, mut second)) =
958                    Structure::half_bond_cylinders(pos1, pos2, Structure::STICK_RADIUS)
959                else {
960                    continue;
961                };
962
963                let vc1 = first.count_vertices();
964                first.insert_attribute(
965                    Mesh::ATTRIBUTE_COLOR,
966                    vec![Vec4::new(0.5, 0.5, 0.5, 0.5); vc1],
967                );
968                atom_map.extend(std::iter::repeat_n(idx_a, vc1));
969
970                let vc2 = second.count_vertices();
971                second.insert_attribute(
972                    Mesh::ATTRIBUTE_COLOR,
973                    vec![Vec4::new(0.5, 0.5, 0.5, 0.5); vc2],
974                );
975                atom_map.extend(std::iter::repeat_n(idx_b, vc2));
976
977                let _ = first.merge(&second);
978                match &mut combined {
979                    None => combined = Some(first),
980                    Some(acc) => {
981                        let _ = acc.merge(&first);
982                    }
983                }
984            }
985        }
986
987        let mesh = combined.unwrap_or_else(|| {
988            Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all())
989        });
990        (mesh, atom_map)
991    }
992
993    fn render_cartoon_mapped(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
994        let segments_per_residue: usize = Self::TUBE_CURVE_SEGMENTS;
995        let cross_segments: usize = Self::TUBE_CROSS_SEGMENTS;
996
997        // Collect (residue_iter_idx, chain_id, BackboneAtoms) for residues whose CA passes the mask.
998        let indexed_backbone: Vec<(usize, String, BackboneAtoms)> = self
999            .pdb
1000            .residues_aminoacid()
1001            .enumerate()
1002            .filter_map(|(res_iter_idx, residue)| {
1003                let ca = residue.find_atom_by_name("CA")?;
1004                if let Some(m) = mask
1005                    && !m.0[ca.index()]
1006                {
1007                    return None;
1008                }
1009                let n = residue.find_atom_by_name("N")?;
1010                let c = residue.find_atom_by_name("C")?;
1011                // O is required so the residue counts as a complete backbone unit,
1012                // matching the previous behaviour, even though only N/CA/C feed the
1013                // dihedral-based secondary-structure classifier.
1014                let _o = residue.find_atom_by_name("O")?;
1015                Some((
1016                    res_iter_idx,
1017                    residue.chain_id().to_string(),
1018                    BackboneAtoms {
1019                        ca: Vec3::from_array(ca.coords()),
1020                        n: Vec3::from_array(n.coords()),
1021                        c: Vec3::from_array(c.coords()),
1022                    },
1023                ))
1024            })
1025            .collect();
1026
1027        if indexed_backbone.is_empty() {
1028            return (
1029                Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all()),
1030                Vec::new(),
1031            );
1032        }
1033
1034        // Split into contiguous backbone segments so the spline never connects
1035        // across a ribbon break. A break is any of: a gap in the masked residue
1036        // sequence, a chain change, or a CA-CA jump larger than a peptide bond
1037        // (missing residues / HETATM insertion). See ferritin-t0h.1.
1038        let mut segments: Vec<Vec<(usize, BackboneAtoms)>> = Vec::new();
1039        let mut current: Vec<(usize, BackboneAtoms)> = Vec::new();
1040        let mut last_chain: Option<String> = None;
1041        for (idx, chain, bb) in indexed_backbone {
1042            if let (Some(last), Some(prev_chain)) = (current.last(), last_chain.as_deref())
1043                && Structure::is_backbone_break(last.0, prev_chain, last.1.ca, idx, &chain, bb.ca)
1044            {
1045                segments.push(std::mem::take(&mut current));
1046            }
1047            last_chain = Some(chain);
1048            current.push((idx, bb));
1049        }
1050        if !current.is_empty() {
1051            segments.push(current);
1052        }
1053
1054        let mut residue_map: Vec<usize> = Vec::new();
1055        let mut combined: Option<Mesh> = None;
1056
1057        for segment in segments {
1058            if segment.len() < 2 {
1059                continue;
1060            }
1061            let (res_indices, backbone_atoms): (Vec<usize>, Vec<BackboneAtoms>) =
1062                segment.into_iter().unzip();
1063            let secondary_structures = Structure::detect_secondary_structure(&backbone_atoms);
1064            let ca_positions: Vec<Vec3> = backbone_atoms.iter().map(|a| a.ca).collect();
1065            let curve = Structure::create_smooth_curve(&ca_positions, segments_per_residue);
1066            let curve_sec: Vec<SecondaryStructure> = (0..curve.len())
1067                .map(|i| {
1068                    secondary_structures
1069                        .get(i / segments_per_residue)
1070                        .copied()
1071                        .unwrap_or(SecondaryStructure::Loop)
1072                })
1073                .collect();
1074            // Per-residue backbone-derived ribbon orientation (ferritin-t0h.3),
1075            // interpolated onto the finer curve so the flat cross-section twists
1076            // smoothly with the real geometry instead of an arbitrary seed.
1077            let residue_normals = Structure::peptide_plane_normals(&ca_positions);
1078            let curve_up: Vec<Vec3> = (0..curve.len())
1079                .map(|i| {
1080                    let res_idx = i / segments_per_residue;
1081                    let t = (i % segments_per_residue) as f32 / segments_per_residue as f32;
1082                    let n0 = residue_normals[res_idx.min(residue_normals.len() - 1)];
1083                    let n1 = residue_normals[(res_idx + 1).min(residue_normals.len() - 1)];
1084                    Structure::nlerp_direction(n0, n1, t)
1085                })
1086                .collect();
1087            let seg_mesh = Structure::generate_cartoon_mesh(&curve, &curve_sec, &curve_up);
1088            let seg_verts = seg_mesh.count_vertices();
1089            // Map each vertex back to its residue iteration index.
1090            for v in 0..seg_verts {
1091                let curve_pt = v / cross_segments;
1092                let res_in_seg = (curve_pt / segments_per_residue).min(res_indices.len() - 1);
1093                residue_map.push(res_indices[res_in_seg]);
1094            }
1095            match &mut combined {
1096                None => combined = Some(seg_mesh),
1097                Some(acc) => {
1098                    let _ = acc.merge(&seg_mesh);
1099                }
1100            }
1101        }
1102
1103        let mesh = combined.unwrap_or_else(|| {
1104            Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all())
1105        });
1106        (mesh, residue_map)
1107    }
1108
1109    fn render_putty_mapped(&self, mask: Option<&AtomMask>) -> (Mesh, Vec<usize>) {
1110        const CROSS_SEGMENTS: usize = Structure::TUBE_CROSS_SEGMENTS;
1111        const CURVE_SEGMENTS: usize = Structure::TUBE_CURVE_SEGMENTS;
1112
1113        // Collect (res_iter_idx, chain_id, CA position) for unmasked residues.
1114        let ca_data: Vec<(usize, String, Vec3)> = self
1115            .pdb
1116            .residues_aminoacid()
1117            .enumerate()
1118            .filter_map(|(res_iter_idx, residue)| {
1119                let ca = residue.find_atom_by_name("CA")?;
1120                if let Some(m) = mask
1121                    && !m.0[ca.index()]
1122                {
1123                    return None;
1124                }
1125                Some((
1126                    res_iter_idx,
1127                    residue.chain_id().to_string(),
1128                    Vec3::from_array(ca.coords()),
1129                ))
1130            })
1131            .collect();
1132
1133        if ca_data.len() < 2 {
1134            return (
1135                Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all()),
1136                Vec::new(),
1137            );
1138        }
1139
1140        // Split into contiguous segments at chain breaks / missing-residue gaps
1141        // so the tube never splines straight through them (ferritin-t0h.1).
1142        let mut segments: Vec<Vec<(usize, Vec3)>> = Vec::new();
1143        let mut current: Vec<(usize, Vec3)> = Vec::new();
1144        let mut last_chain: Option<String> = None;
1145        for (idx, chain, ca) in ca_data {
1146            if let (Some(last), Some(prev_chain)) = (current.last(), last_chain.as_deref())
1147                && Structure::is_backbone_break(last.0, prev_chain, last.1, idx, &chain, ca)
1148            {
1149                segments.push(std::mem::take(&mut current));
1150            }
1151            last_chain = Some(chain);
1152            current.push((idx, ca));
1153        }
1154        if !current.is_empty() {
1155            segments.push(current);
1156        }
1157
1158        let mut residue_map: Vec<usize> = Vec::new();
1159        let mut combined: Option<Mesh> = None;
1160
1161        for segment in segments {
1162            if segment.len() < 2 {
1163                continue;
1164            }
1165            let (res_indices, ca_positions): (Vec<usize>, Vec<Vec3>) = segment.into_iter().unzip();
1166            let curve = Structure::create_smooth_curve(&ca_positions, CURVE_SEGMENTS);
1167            let radii = Structure::synthetic_putty_radii(curve.len());
1168            let seg_mesh = Structure::generate_tube_mesh_varying(&curve, &radii, CROSS_SEGMENTS);
1169            let seg_verts = seg_mesh.count_vertices();
1170            for v in 0..seg_verts {
1171                let curve_pt = v / CROSS_SEGMENTS;
1172                let res_in_list = (curve_pt / CURVE_SEGMENTS).min(res_indices.len() - 1);
1173                residue_map.push(res_indices[res_in_list]);
1174            }
1175            match &mut combined {
1176                None => combined = Some(seg_mesh),
1177                Some(acc) => {
1178                    let _ = acc.merge(&seg_mesh);
1179                }
1180            }
1181        }
1182
1183        let mesh = combined.unwrap_or_else(|| {
1184            Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all())
1185        });
1186        (mesh, residue_map)
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193    use crate::selection::{AtomMask, evaluate_selector};
1194    use ferritin_core::load_model;
1195    use ferritin_molviewspec::molviewspec::nodes::{ComponentSelector, ComponentSelectorT};
1196    use ferritin_test_data::TestFile;
1197
1198    // ferritin-c1k.1: BallAndStick balls must read as thicker than the sticks
1199    // joining them, not as one uniform-radius licorice tube.
1200    #[test]
1201    #[allow(clippy::assertions_on_constants)] // guards an invariant between two consts
1202    fn test_ballandstick_stick_thinner_than_ball() {
1203        assert!(
1204            Structure::STICK_RADIUS < Structure::BALL_RADIUS,
1205            "stick radius {} must be smaller than ball radius {}",
1206            Structure::STICK_RADIUS,
1207            Structure::BALL_RADIUS
1208        );
1209    }
1210
1211    // ferritin-t0h.7: isolated water spheres must render smaller than a normal
1212    // atom so they read as background clutter instead of competing with the
1213    // real structure in BallAndStick/Spacefill.
1214    #[test]
1215    fn test_is_water_residue_matches_common_names_only() {
1216        assert!(Structure::is_water_residue("HOH"));
1217        assert!(Structure::is_water_residue("WAT"));
1218        assert!(Structure::is_water_residue("H2O"));
1219        assert!(!Structure::is_water_residue("ALA"));
1220        assert!(!Structure::is_water_residue("ZN"));
1221    }
1222
1223    #[test]
1224    #[allow(clippy::assertions_on_constants)] // guards an invariant on a const
1225    fn test_water_radius_scale_shrinks_but_does_not_vanish() {
1226        assert!(
1227            Structure::WATER_RADIUS_SCALE > 0.0 && Structure::WATER_RADIUS_SCALE < 1.0,
1228            "water radius scale {} must shrink (not disable/invert) the sphere",
1229            Structure::WATER_RADIUS_SCALE
1230        );
1231    }
1232
1233    /// Radius of the sphere mesh vertices mapped to `atom_idx`: max distance
1234    /// from the atom's own center to any vertex the atom_map attributes to it.
1235    fn mesh_sphere_radius_for_atom(
1236        mesh: &Mesh,
1237        atom_map: &[usize],
1238        atom_idx: usize,
1239        center: Vec3,
1240    ) -> f32 {
1241        let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap() {
1242            bevy::render::mesh::VertexAttributeValues::Float32x3(v) => v,
1243            _ => panic!("unexpected position format"),
1244        };
1245        atom_map
1246            .iter()
1247            .zip(positions.iter())
1248            .filter(|&(&idx, _)| idx == atom_idx)
1249            .map(|(_, p)| (Vec3::from_array(*p) - center).length())
1250            .fold(0.0f32, f32::max)
1251    }
1252
1253    #[test]
1254    fn test_spacefill_water_oxygen_smaller_than_protein_oxygen() -> anyhow::Result<()> {
1255        use ferritin_core::info::elements::Element;
1256
1257        let (molfile, _handle) = TestFile::mvs_4hhb().create_temp()?;
1258        let model = load_model(molfile).unwrap();
1259        let structure = Structure::builder()
1260            .pdb(model)
1261            .rendertype(RenderOptions::Solid)
1262            .build();
1263
1264        let water_o = structure
1265            .pdb
1266            .atoms()
1267            .find(|a| Structure::is_water_residue(a.residue_name()) && a.element() == Element::O)
1268            .map(|a| a.index())
1269            .expect("4hhb fixture must contain a water oxygen atom");
1270        let protein_o = structure
1271            .pdb
1272            .atoms()
1273            .find(|a| !Structure::is_water_residue(a.residue_name()) && a.element() == Element::O)
1274            .map(|a| a.index())
1275            .expect("4hhb fixture must contain a non-water oxygen atom");
1276
1277        let (mesh, atom_map) = structure.to_mesh_with_atom_map(None);
1278        let water_center = Vec3::from_array(structure.pdb.coord(water_o));
1279        let protein_center = Vec3::from_array(structure.pdb.coord(protein_o));
1280        let water_r = mesh_sphere_radius_for_atom(&mesh, &atom_map, water_o, water_center);
1281        let protein_r = mesh_sphere_radius_for_atom(&mesh, &atom_map, protein_o, protein_center);
1282
1283        assert!(water_r > 0.0 && protein_r > 0.0);
1284        assert!(
1285            water_r < protein_r,
1286            "water oxygen sphere ({water_r}) should be smaller than protein oxygen sphere ({protein_r})"
1287        );
1288        Ok(())
1289    }
1290
1291    #[test]
1292    fn test_ballandstick_water_smaller_than_ball_radius() -> anyhow::Result<()> {
1293        let (molfile, _handle) = TestFile::mvs_4hhb().create_temp()?;
1294        let model = load_model(molfile).unwrap();
1295        let structure = Structure::builder()
1296            .pdb(model)
1297            .rendertype(RenderOptions::BallAndStick)
1298            .build();
1299
1300        // Water has no bonds, so its BallAndStick vertices are pure sphere —
1301        // safe to compare directly against BALL_RADIUS (unlike a bonded atom,
1302        // whose stick geometry can extend past the ball radius).
1303        let water_idx = structure
1304            .pdb
1305            .atoms()
1306            .find(|a| Structure::is_water_residue(a.residue_name()))
1307            .map(|a| a.index())
1308            .expect("4hhb fixture must contain a water atom");
1309
1310        let (mesh, atom_map) = structure.to_mesh_with_atom_map(None);
1311        let center = Vec3::from_array(structure.pdb.coord(water_idx));
1312        let water_r = mesh_sphere_radius_for_atom(&mesh, &atom_map, water_idx, center);
1313
1314        assert!(water_r > 0.0);
1315        assert!(
1316            water_r < Structure::BALL_RADIUS,
1317            "water sphere ({water_r}) should be smaller than full BALL_RADIUS ({})",
1318            Structure::BALL_RADIUS
1319        );
1320        Ok(())
1321    }
1322
1323    // ferritin-c1k.2: a bond must be split at its midpoint so each half can be
1324    // colored/mapped to its own endpoint atom, instead of the whole stick
1325    // taking on a single atom's color.
1326
1327    #[test]
1328    fn test_half_bond_cylinders_splits_at_midpoint() {
1329        let pos1 = Vec3::new(0.0, 0.0, 0.0);
1330        let pos2 = Vec3::new(0.0, 4.0, 0.0);
1331        let (first, second) = Structure::half_bond_cylinders(pos1, pos2, 0.1)
1332            .expect("a valid (non-degenerate) bond must produce two halves");
1333
1334        let y_range = |mesh: &Mesh| -> (f32, f32) {
1335            let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap() {
1336                bevy::render::mesh::VertexAttributeValues::Float32x3(v) => v,
1337                _ => panic!("unexpected position format"),
1338            };
1339            positions.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| {
1340                (lo.min(p[1]), hi.max(p[1]))
1341            })
1342        };
1343
1344        let (first_min, first_max) = y_range(&first);
1345        let (second_min, second_max) = y_range(&second);
1346
1347        assert!(
1348            first_min >= -1e-3 && first_max <= 2.0 + 1e-3,
1349            "first half should span [0, midpoint=2.0], got [{first_min}, {first_max}]"
1350        );
1351        assert!(
1352            second_min >= 2.0 - 1e-3 && second_max <= 4.0 + 1e-3,
1353            "second half should span [midpoint=2.0, 4], got [{second_min}, {second_max}]"
1354        );
1355    }
1356
1357    #[test]
1358    fn test_half_bond_cylinders_degenerate_returns_none() {
1359        let p = Vec3::new(1.0, 2.0, 3.0);
1360        assert!(Structure::half_bond_cylinders(p, p, 0.1).is_none());
1361    }
1362
1363    #[test]
1364    fn test_ballandstick_bond_second_endpoint_gets_own_atom_map_entries() -> anyhow::Result<()> {
1365        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
1366        let model = load_model(molfile).unwrap();
1367
1368        // Find an atom that is only ever a bond's *second* endpoint (atom_b),
1369        // never its first (atom_a). Under the old bug, every bond's vertices
1370        // mapped only to atom_a, so this atom's atom_map count would be exactly
1371        // its own sphere's vertex count with zero contribution from bonds.
1372        let atom_a: std::collections::HashSet<usize> = model
1373            .hierarchy
1374            .bonds
1375            .atom_a
1376            .iter()
1377            .map(|&x| x as usize)
1378            .collect();
1379        let target_idx = model
1380            .hierarchy
1381            .bonds
1382            .atom_b
1383            .iter()
1384            .map(|&x| x as usize)
1385            .find(|idx| !atom_a.contains(idx))
1386            .expect("expected an atom that is only ever a bond's second endpoint");
1387
1388        let structure = Structure::builder()
1389            .pdb(model)
1390            .rendertype(RenderOptions::BallAndStick)
1391            .build();
1392        let (mesh, atom_map) = structure.to_mesh_with_atom_map(None);
1393        assert_eq!(mesh.count_vertices(), atom_map.len());
1394
1395        let sphere_vc = Sphere::new(Structure::BALL_RADIUS)
1396            .mesh()
1397            .build()
1398            .count_vertices();
1399        let count_in_map = atom_map.iter().filter(|&&i| i == target_idx).count();
1400        assert!(
1401            count_in_map > sphere_vc,
1402            "atom {target_idx} is only ever a bond's second endpoint; its atom_map \
1403             count ({count_in_map}) must exceed its own sphere's vertex count \
1404             ({sphere_vc}), proving a bond's second half maps to it instead of \
1405             every bond vertex mapping only to the first endpoint"
1406        );
1407        Ok(())
1408    }
1409
1410    #[test]
1411    fn test_pdb_to_mesh() -> anyhow::Result<()> {
1412        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1413        let model = load_model(molfile).unwrap();
1414        let structure = Structure::builder().pdb(model).build();
1415        assert_eq!(structure.pdb.n_atoms(), 2154);
1416        let _mesh = structure.to_mesh();
1417        // assert_eq!(_mesh.count_vertices(), 779748);
1418        Ok(())
1419    }
1420
1421    // ferritin-t0h.1: cartoon/putty must not spline across chain breaks.
1422
1423    #[test]
1424    fn test_backbone_break_detection() {
1425        let a = Vec3::new(0.0, 0.0, 0.0);
1426        let b = Vec3::new(3.8, 0.0, 0.0); // one peptide bond away
1427        // Contiguous, same chain, adjacent CAs → no break.
1428        assert!(!Structure::is_backbone_break(0, "A", a, 1, "A", b));
1429        // Chain change → break, even if spatially close.
1430        assert!(Structure::is_backbone_break(0, "A", a, 1, "B", b));
1431        // Residue-index gap (e.g. masked-out residue) → break.
1432        assert!(Structure::is_backbone_break(0, "A", a, 2, "A", b));
1433        // Large CA-CA jump (missing residues) → break.
1434        let far = Vec3::new(20.0, 0.0, 0.0);
1435        assert!(Structure::is_backbone_break(0, "A", a, 1, "A", far));
1436    }
1437
1438    #[test]
1439    fn test_sweep_frames_are_continuous_and_orthonormal() {
1440        // A curve that kinks sharply and passes near the global Y axis — exactly
1441        // the case where the old tangent×Y frame flipped and produced spikes.
1442        let curve = vec![
1443            Vec3::new(0.0, 0.0, 0.0),
1444            Vec3::new(1.0, 0.2, 0.0),
1445            Vec3::new(1.5, 1.5, 0.0), // turns to head up +Y
1446            Vec3::new(1.6, 3.0, 0.2),
1447            Vec3::new(1.0, 4.0, 1.0),
1448            Vec3::new(0.0, 4.2, 2.0),
1449        ];
1450        let frames = Structure::sweep_frames(&curve);
1451        assert_eq!(frames.len(), curve.len());
1452        for (i, (right, up)) in frames.iter().enumerate() {
1453            assert!((right.length() - 1.0).abs() < 1e-3, "right[{i}] not unit");
1454            assert!((up.length() - 1.0).abs() < 1e-3, "up[{i}] not unit");
1455            assert!(right.dot(*up).abs() < 1e-3, "right·up[{i}] not orthogonal");
1456        }
1457        // Consecutive frames must not flip: the minimal-rotation frame keeps the
1458        // "right" vector pointing broadly the same way step to step (dot > 0).
1459        for i in 1..frames.len() {
1460            assert!(
1461                frames[i].0.dot(frames[i - 1].0) > 0.0,
1462                "frame right vector flipped between {} and {i}",
1463                i - 1
1464            );
1465        }
1466    }
1467
1468    // ferritin-t0h.3: ribbon orientation should track real backbone geometry
1469    // (peptide-plane normal), not an arbitrary parallel-transported seed.
1470
1471    #[test]
1472    fn test_peptide_plane_normals_sign_corrected_and_unit() {
1473        // A zigzag CA trace mimicking an extended (beta-strand) backbone: the
1474        // raw Carson-Bugg cross product flips sign roughly every residue here,
1475        // which is exactly what the sign-correction pass must undo.
1476        let ca_positions = vec![
1477            Vec3::new(0.0, 0.0, 0.0),
1478            Vec3::new(1.0, 1.0, 0.0),
1479            Vec3::new(2.0, 0.0, 0.0),
1480            Vec3::new(3.0, 1.0, 0.0),
1481            Vec3::new(4.0, 0.0, 0.0),
1482            Vec3::new(5.0, 1.0, 0.0),
1483        ];
1484        let normals = Structure::peptide_plane_normals(&ca_positions);
1485        assert_eq!(normals.len(), ca_positions.len());
1486        for (i, n) in normals.iter().enumerate() {
1487            assert!((n.length() - 1.0).abs() < 1e-3, "normals[{i}] not unit");
1488        }
1489        for i in 1..normals.len() {
1490            assert!(
1491                normals[i].dot(normals[i - 1]) >= 0.0,
1492                "peptide-plane normal flipped between residue {} and {i}",
1493                i - 1
1494            );
1495        }
1496    }
1497
1498    #[test]
1499    fn test_sweep_frames_oriented_tracks_hint_and_stays_continuous() {
1500        // Straight curve along +Z; hint consistently points toward +X, so `up`
1501        // (perpendicular to the tangent) should end up parallel to +X at every
1502        // point, not just whatever a parallel-transported seed happened to pick.
1503        let curve = vec![
1504            Vec3::new(0.0, 0.0, 0.0),
1505            Vec3::new(0.0, 0.0, 1.0),
1506            Vec3::new(0.0, 0.0, 2.0),
1507            Vec3::new(0.0, 0.0, 3.0),
1508        ];
1509        let hints = vec![Vec3::X; curve.len()];
1510        let frames = Structure::sweep_frames_oriented(&curve, &hints);
1511        assert_eq!(frames.len(), curve.len());
1512        for (i, (right, up)) in frames.iter().enumerate() {
1513            assert!((right.length() - 1.0).abs() < 1e-3, "right[{i}] not unit");
1514            assert!((up.length() - 1.0).abs() < 1e-3, "up[{i}] not unit");
1515            assert!(right.dot(*up).abs() < 1e-3, "right·up[{i}] not orthogonal");
1516            assert!(
1517                up.dot(Vec3::X).abs() > 0.99,
1518                "up[{i}]={up:?} should track the +X hint, not an arbitrary seed"
1519            );
1520        }
1521    }
1522
1523    #[test]
1524    fn test_sweep_frames_oriented_falls_back_when_hint_degenerate() {
1525        // Hint parallel to the tangent (straight +Z curve, hint also +Z) can't
1526        // be projected to a perpendicular direction; must fall back to a valid
1527        // orthonormal frame instead of producing NaN/zero vectors.
1528        let curve = vec![
1529            Vec3::new(0.0, 0.0, 0.0),
1530            Vec3::new(0.0, 0.0, 1.0),
1531            Vec3::new(0.0, 0.0, 2.0),
1532        ];
1533        let hints = vec![Vec3::Z; curve.len()];
1534        let frames = Structure::sweep_frames_oriented(&curve, &hints);
1535        for (i, (right, up)) in frames.iter().enumerate() {
1536            assert!(right.is_finite() && up.is_finite(), "frame[{i}] not finite");
1537            assert!((right.length() - 1.0).abs() < 1e-3, "right[{i}] not unit");
1538            assert!((up.length() - 1.0).abs() < 1e-3, "up[{i}] not unit");
1539            assert!(right.dot(*up).abs() < 1e-3, "right·up[{i}] not orthogonal");
1540        }
1541    }
1542
1543    #[test]
1544    fn test_cartoon_segments_multi_chain_4hhb() -> anyhow::Result<()> {
1545        // 4hhb has 4 protein chains; the cartoon must render as several
1546        // disconnected ribbons, not one curve splined through all of them.
1547        let (molfile, _handle) = TestFile::mvs_4hhb().create_temp()?;
1548        let model = load_model(molfile).unwrap();
1549        let structure = Structure::builder()
1550            .pdb(model)
1551            .rendertype(RenderOptions::Cartoon)
1552            .build();
1553
1554        let (mesh, residue_map) = structure.render_cartoon_mapped(None);
1555        assert!(mesh.count_vertices() > 0, "cartoon mesh must not be empty");
1556        assert_eq!(mesh.count_vertices(), residue_map.len());
1557
1558        // A single unsegmented curve through all N amino-acid residues would
1559        // produce (N-1)*segments_per_residue ring centers. Each additional
1560        // segment removes one inter-residue span, so a genuinely split ribbon
1561        // has strictly fewer ring centers than the unsegmented curve.
1562        let n_res = structure.pdb.residues_aminoacid().count();
1563        let cross_segments = Structure::TUBE_CROSS_SEGMENTS;
1564        let segments_per_residue = Structure::TUBE_CURVE_SEGMENTS;
1565        let unsegmented_rings = (n_res - 1) * segments_per_residue;
1566        let actual_rings = mesh.count_vertices() / cross_segments;
1567        assert!(
1568            actual_rings < unsegmented_rings,
1569            "expected segmentation to drop ring centers: {actual_rings} vs {unsegmented_rings}"
1570        );
1571        Ok(())
1572    }
1573
1574    #[test]
1575    fn test_pdb_to_mesh_cartoon() -> anyhow::Result<()> {
1576        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1577        let model = load_model(molfile).unwrap();
1578        let structure = Structure::builder()
1579            .pdb(model)
1580            .rendertype(RenderOptions::Cartoon)
1581            .build();
1582
1583        assert_eq!(structure.pdb.n_atoms(), 2154);
1584        let mesh = structure.to_mesh();
1585        assert!(mesh.count_vertices() > 0);
1586        Ok(())
1587    }
1588
1589    #[test]
1590    fn test_render_wireframe() -> anyhow::Result<()> {
1591        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1592        let model = load_model(molfile).unwrap();
1593        let structure = Structure::builder()
1594            .pdb(model)
1595            .rendertype(RenderOptions::Wireframe)
1596            .build();
1597
1598        let mesh = structure.to_mesh();
1599
1600        let vertex_count = mesh.count_vertices();
1601        assert!(vertex_count > 0, "wireframe mesh has no vertices");
1602        // Line rep is rendered as thin triangle cylinders, not a GPU LineList,
1603        // so it gets real normals and doesn't alias to 1px (ferritin-t0h.9).
1604        assert_eq!(mesh.primitive_topology(), PrimitiveTopology::TriangleList);
1605        assert!(
1606            mesh.attribute(Mesh::ATTRIBUTE_NORMAL).is_some(),
1607            "wireframe mesh should carry normals for lighting"
1608        );
1609
1610        Ok(())
1611    }
1612
1613    // -----------------------------------------------------------------------
1614    // T2-25 to T2-27: Solid masked rendering
1615    // -----------------------------------------------------------------------
1616
1617    #[test]
1618    fn test_solid_none_mask_regression() -> anyhow::Result<()> {
1619        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1620        let model = load_model(molfile).unwrap();
1621        let structure = Structure::builder()
1622            .pdb(model)
1623            .rendertype(RenderOptions::Solid)
1624            .build();
1625        let (mesh_none, map_none) = structure.to_mesh_with_atom_map(None);
1626        assert!(
1627            mesh_none.count_vertices() > 0,
1628            "None mask must produce vertices"
1629        );
1630        assert_eq!(
1631            mesh_none.count_vertices(),
1632            map_none.len(),
1633            "atom_map len must equal vertex count"
1634        );
1635        Ok(())
1636    }
1637
1638    #[test]
1639    fn test_solid_protein_mask_fewer_verts() -> anyhow::Result<()> {
1640        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1641        let model = load_model(molfile).unwrap();
1642        let ac = ferritin_core::AtomCollection::from(&model);
1643        let prot_mask = evaluate_selector(
1644            &ComponentSelector::Selector(ComponentSelectorT::Protein),
1645            &ac,
1646        );
1647
1648        // Build a Model from the original (not filtered) for rendering.
1649        let structure = Structure::builder()
1650            .pdb(model)
1651            .rendertype(RenderOptions::Solid)
1652            .build();
1653        let (mesh_all, _) = structure.to_mesh_with_atom_map(None);
1654        let (mesh_prot, map_prot) = structure.to_mesh_with_atom_map(Some(&prot_mask));
1655
1656        assert!(
1657            mesh_prot.count_vertices() > 0,
1658            "protein mask must still produce vertices"
1659        );
1660        assert!(
1661            mesh_prot.count_vertices() <= mesh_all.count_vertices(),
1662            "masked mesh must have <= vertices than full mesh"
1663        );
1664        assert_eq!(
1665            mesh_prot.count_vertices(),
1666            map_prot.len(),
1667            "atom_map len must equal vertex count for protein mask"
1668        );
1669        Ok(())
1670    }
1671
1672    #[test]
1673    fn test_solid_all_false_mask_empty() -> anyhow::Result<()> {
1674        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1675        let model = load_model(molfile).unwrap();
1676        let n = model.n_atoms();
1677        let empty_mask = AtomMask(vec![false; n]);
1678        let structure = Structure::builder()
1679            .pdb(model)
1680            .rendertype(RenderOptions::Solid)
1681            .build();
1682        let (mesh, map) = structure.to_mesh_with_atom_map(Some(&empty_mask));
1683        assert_eq!(
1684            mesh.count_vertices(),
1685            0,
1686            "all-false mask must produce 0 vertices"
1687        );
1688        assert_eq!(map.len(), 0);
1689        Ok(())
1690    }
1691
1692    // -----------------------------------------------------------------------
1693    // T2-28 to T2-30: Wireframe masked rendering
1694    // -----------------------------------------------------------------------
1695
1696    #[test]
1697    fn test_wireframe_none_mask_atom_map_consistency() -> anyhow::Result<()> {
1698        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1699        let model = load_model(molfile).unwrap();
1700        let structure = Structure::builder()
1701            .pdb(model)
1702            .rendertype(RenderOptions::Wireframe)
1703            .build();
1704        let (mesh, map) = structure.to_mesh_with_atom_map(None);
1705        assert_eq!(
1706            mesh.count_vertices(),
1707            map.len(),
1708            "wireframe: atom_map len must equal vertex count"
1709        );
1710        assert_eq!(
1711            mesh.count_vertices() % 2,
1712            0,
1713            "wireframe: vertex count must be even"
1714        );
1715        Ok(())
1716    }
1717
1718    #[test]
1719    fn test_wireframe_protein_mask_fewer_edges() -> anyhow::Result<()> {
1720        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1721        let model = load_model(molfile).unwrap();
1722        let ac = ferritin_core::AtomCollection::from(&model);
1723        let prot_mask = evaluate_selector(
1724            &ComponentSelector::Selector(ComponentSelectorT::Protein),
1725            &ac,
1726        );
1727        let structure = Structure::builder()
1728            .pdb(model)
1729            .rendertype(RenderOptions::Wireframe)
1730            .build();
1731        let (mesh_all, _) = structure.to_mesh_with_atom_map(None);
1732        let (mesh_prot, map_prot) = structure.to_mesh_with_atom_map(Some(&prot_mask));
1733        assert!(
1734            mesh_prot.count_vertices() <= mesh_all.count_vertices(),
1735            "masked wireframe must have <= edges"
1736        );
1737        assert_eq!(mesh_prot.count_vertices(), map_prot.len());
1738        Ok(())
1739    }
1740
1741    #[test]
1742    fn test_wireframe_all_false_empty() -> anyhow::Result<()> {
1743        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1744        let model = load_model(molfile).unwrap();
1745        let empty_mask = AtomMask(vec![false; model.n_atoms()]);
1746        let structure = Structure::builder()
1747            .pdb(model)
1748            .rendertype(RenderOptions::Wireframe)
1749            .build();
1750        let (mesh, map) = structure.to_mesh_with_atom_map(Some(&empty_mask));
1751        assert_eq!(mesh.count_vertices(), 0);
1752        assert_eq!(map.len(), 0);
1753        Ok(())
1754    }
1755
1756    // -----------------------------------------------------------------------
1757    // T2-31 to T2-33: Cartoon residue map
1758    // -----------------------------------------------------------------------
1759
1760    #[test]
1761    fn test_cartoon_none_mask_residue_map() -> anyhow::Result<()> {
1762        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1763        let model = load_model(molfile).unwrap();
1764        let structure = Structure::builder()
1765            .pdb(model)
1766            .rendertype(RenderOptions::Cartoon)
1767            .build();
1768        let (mesh, residue_map) = structure.to_mesh_with_residue_map(None);
1769        assert!(mesh.count_vertices() > 0);
1770        assert_eq!(
1771            mesh.count_vertices(),
1772            residue_map.len(),
1773            "cartoon: residue_map len must equal vertex count"
1774        );
1775        Ok(())
1776    }
1777
1778    #[test]
1779    fn test_cartoon_protein_mask_fewer_verts() -> anyhow::Result<()> {
1780        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1781        let model = load_model(molfile).unwrap();
1782        let ac = ferritin_core::AtomCollection::from(&model);
1783        let prot_mask = evaluate_selector(
1784            &ComponentSelector::Selector(ComponentSelectorT::Protein),
1785            &ac,
1786        );
1787        let structure = Structure::builder()
1788            .pdb(model)
1789            .rendertype(RenderOptions::Cartoon)
1790            .build();
1791        let (mesh_all, _) = structure.to_mesh_with_residue_map(None);
1792        let (mesh_prot, map_prot) = structure.to_mesh_with_residue_map(Some(&prot_mask));
1793        assert!(mesh_prot.count_vertices() <= mesh_all.count_vertices());
1794        assert_eq!(mesh_prot.count_vertices(), map_prot.len());
1795        Ok(())
1796    }
1797
1798    #[test]
1799    fn test_cartoon_all_false_mask_empty() -> anyhow::Result<()> {
1800        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1801        let model = load_model(molfile).unwrap();
1802        let empty_mask = AtomMask(vec![false; model.n_atoms()]);
1803        let structure = Structure::builder()
1804            .pdb(model)
1805            .rendertype(RenderOptions::Cartoon)
1806            .build();
1807        let (mesh, map) = structure.to_mesh_with_residue_map(Some(&empty_mask));
1808        assert_eq!(mesh.count_vertices(), 0);
1809        assert_eq!(map.len(), 0);
1810        Ok(())
1811    }
1812
1813    // -----------------------------------------------------------------------
1814    // T2-34 to T2-40: atom_map and residue_map invariants
1815    // -----------------------------------------------------------------------
1816
1817    #[test]
1818    fn test_atom_map_all_indices_valid() -> anyhow::Result<()> {
1819        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1820        let model = load_model(molfile).unwrap();
1821        let n = model.n_atoms();
1822        let structure = Structure::builder()
1823            .pdb(model)
1824            .rendertype(RenderOptions::Solid)
1825            .build();
1826        let (_, atom_map) = structure.to_mesh_with_atom_map(None);
1827        for &idx in &atom_map {
1828            assert!(
1829                idx < n,
1830                "atom_map index {} out of bounds (n_atoms={})",
1831                idx,
1832                n
1833            );
1834        }
1835        Ok(())
1836    }
1837
1838    #[test]
1839    fn test_wireframe_atom_map_all_indices_valid() -> anyhow::Result<()> {
1840        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1841        let model = load_model(molfile).unwrap();
1842        let n = model.n_atoms();
1843        let structure = Structure::builder()
1844            .pdb(model)
1845            .rendertype(RenderOptions::Wireframe)
1846            .build();
1847        let (_, atom_map) = structure.to_mesh_with_atom_map(None);
1848        for &idx in &atom_map {
1849            assert!(idx < n, "wireframe atom_map index {} out of bounds", idx);
1850        }
1851        Ok(())
1852    }
1853
1854    #[test]
1855    fn test_residue_map_values_non_decreasing() -> anyhow::Result<()> {
1856        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1857        let model = load_model(molfile).unwrap();
1858        let structure = Structure::builder()
1859            .pdb(model)
1860            .rendertype(RenderOptions::Cartoon)
1861            .build();
1862        let (_, residue_map) = structure.to_mesh_with_residue_map(None);
1863        for w in residue_map.windows(2) {
1864            assert!(
1865                w[0] <= w[1],
1866                "residue_map must be non-decreasing but got {} then {}",
1867                w[0],
1868                w[1]
1869            );
1870        }
1871        Ok(())
1872    }
1873
1874    #[test]
1875    fn test_putty_residue_map_length() -> anyhow::Result<()> {
1876        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1877        let model = load_model(molfile).unwrap();
1878        let structure = Structure::builder()
1879            .pdb(model)
1880            .rendertype(RenderOptions::Putty)
1881            .build();
1882        let (mesh, residue_map) = structure.to_mesh_with_residue_map(None);
1883        assert_eq!(
1884            mesh.count_vertices(),
1885            residue_map.len(),
1886            "putty: residue_map len must equal vertex count"
1887        );
1888        Ok(())
1889    }
1890
1891    // ferritin-ala.10: Putty rendered as a constant-radius tube, pixel-identical
1892    // to Cartoon's loop tube, since AtomCollection has no per-atom b_factor yet.
1893    // Until that lands (ferritin-clv), the tube radius should at least vary
1894    // along the chain as a visual placeholder.
1895    #[test]
1896    fn test_synthetic_putty_radii_vary() {
1897        let radii = Structure::synthetic_putty_radii(40);
1898        let min = radii.iter().cloned().fold(f32::MAX, f32::min);
1899        let max = radii.iter().cloned().fold(f32::MIN, f32::max);
1900        assert!(
1901            max - min > 0.1,
1902            "putty radii should vary noticeably along the chain, got range {min}..{max}"
1903        );
1904        assert!(
1905            radii.iter().all(|&r| r > 0.0),
1906            "all synthetic putty radii must stay positive"
1907        );
1908    }
1909
1910    #[test]
1911    fn test_putty_mesh_radius_varies_along_chain() -> anyhow::Result<()> {
1912        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1913        let model = load_model(molfile).unwrap();
1914        let structure = Structure::builder()
1915            .pdb(model)
1916            .rendertype(RenderOptions::Putty)
1917            .build();
1918        let mesh = structure.to_mesh();
1919        let positions = match mesh
1920            .attribute(Mesh::ATTRIBUTE_POSITION)
1921            .expect("putty mesh must have positions")
1922        {
1923            bevy::render::mesh::VertexAttributeValues::Float32x3(v) => v,
1924            _ => panic!("unexpected position format"),
1925        };
1926        // Cross-section rings are TUBE_CROSS_SEGMENTS vertices each; compare each
1927        // ring's average radial distance from its own centroid.
1928        let ring_size = Structure::TUBE_CROSS_SEGMENTS;
1929        let ring_radius = |ring: &[[f32; 3]]| -> f32 {
1930            let centroid = ring
1931                .iter()
1932                .fold(Vec3::ZERO, |acc, p| acc + Vec3::from_array(*p))
1933                / ring.len() as f32;
1934            ring.iter()
1935                .map(|p| (Vec3::from_array(*p) - centroid).length())
1936                .sum::<f32>()
1937                / ring.len() as f32
1938        };
1939        let ring_radii: Vec<f32> = positions
1940            .chunks(ring_size)
1941            .filter(|chunk| chunk.len() == ring_size)
1942            .map(ring_radius)
1943            .collect();
1944        let min = ring_radii.iter().cloned().fold(f32::MAX, f32::min);
1945        let max = ring_radii.iter().cloned().fold(f32::MIN, f32::max);
1946        assert!(
1947            max - min > 0.1,
1948            "putty mesh cross-section radius should vary along the chain, got range {min}..{max}"
1949        );
1950        Ok(())
1951    }
1952
1953    #[test]
1954    fn test_masked_solid_atom_map_indices_in_mask() -> anyhow::Result<()> {
1955        let (molfile, _handle) = TestFile::protein_04().create_temp()?;
1956        let model = load_model(molfile).unwrap();
1957        let ac = ferritin_core::AtomCollection::from(&model);
1958        let prot_mask = evaluate_selector(
1959            &ComponentSelector::Selector(ComponentSelectorT::Protein),
1960            &ac,
1961        );
1962        let structure = Structure::builder()
1963            .pdb(model)
1964            .rendertype(RenderOptions::Solid)
1965            .build();
1966        let (_, atom_map) = structure.to_mesh_with_atom_map(Some(&prot_mask));
1967        for &idx in &atom_map {
1968            assert!(
1969                prot_mask.0[idx],
1970                "atom_map must only contain indices that pass the mask"
1971            );
1972        }
1973        Ok(())
1974    }
1975}