Skip to main content

ferritin_structure_mesh/
structure.rs

1//! Structure.
2//!
3//! Struct for rendering protein structures
4//!
5
6use super::ColorScheme;
7use bevy::prelude::*;
8use bon::Builder;
9use ferritin_core::AtomCollection;
10
11/// Rendering options for protein structures
12#[derive(Clone)]
13pub enum RenderOptions {
14    Wireframe,
15    Cartoon,
16    BallAndStick,
17    Solid,
18    Putty,
19}
20
21/// Structure represents a molecular structure that can be rendered
22#[derive(Builder, Clone)]
23pub struct Structure {
24    pdb: AtomCollection,
25    #[builder(default = RenderOptions::Solid)]
26    rendertype: RenderOptions,
27    #[builder(default = ColorScheme::ByAtomType)]
28    color_scheme: ColorScheme,
29    #[builder(default = StandardMaterial::default())]
30    material: StandardMaterial,
31}
32
33// Basic implementation without feature gates
34impl Structure {
35    // Basic methods that don't depend on bevy or rerun
36}
37
38impl Structure {
39    /// Convert the structure to a mesh using the specified render type
40    pub fn to_mesh(&self) -> Mesh {
41        match self.rendertype {
42            RenderOptions::Wireframe => self.render_wireframe(),
43            RenderOptions::Cartoon => self.render_cartoon(),
44            RenderOptions::BallAndStick => self.render_ballandstick(),
45            RenderOptions::Solid => self.render_spheres(),
46            RenderOptions::Putty => self.render_putty(),
47        }
48    }
49
50    /// Get the material used for rendering
51    pub fn get_material(&self) -> StandardMaterial {
52        self.material.clone()
53    }
54
55    // Rendering method implementations
56    fn render_wireframe(&self) -> Mesh {
57        self.create_sphere_mesh(0.5)
58    }
59
60    fn render_cartoon(&self) -> Mesh {
61        self.create_sphere_mesh(1.0)
62    }
63
64    /// Ball-and-stick rendering: small spheres (radius 0.4) at each atom position,
65    /// connected by cylinder meshes along each bond.
66    ///
67    /// Bonds are sourced from `AtomCollection::get_bonds()`. If no bonds are present,
68    /// the method renders spheres only. Call `connect_via_residue_names()` on the
69    /// AtomCollection before building the Structure to populate bonds.
70    ///
71    /// Produces: ATTRIBUTE_POSITION, ATTRIBUTE_NORMAL, ATTRIBUTE_UV_0; U32 indices;
72    /// TriangleList topology. No ATTRIBUTE_COLOR.
73    fn render_ballandstick(&self) -> Mesh {
74        const ATOM_RADIUS: f32 = 0.4;
75        const BOND_RADIUS: f32 = 0.15;
76
77        let mut positions: Vec<[f32; 3]> = Vec::new();
78        let mut normals: Vec<[f32; 3]> = Vec::new();
79        let mut uvs: Vec<[f32; 2]> = Vec::new();
80        let mut indices: Vec<u32> = Vec::new();
81
82        // Add sphere for each atom
83        for idx in 0..self.pdb.get_size() {
84            let coord = self.pdb.get_coord(idx);
85            let center = Vec3::new(coord[0], coord[1], coord[2]);
86            Self::append_sphere_geometry(
87                center,
88                ATOM_RADIUS,
89                &mut positions,
90                &mut normals,
91                &mut uvs,
92                &mut indices,
93            );
94        }
95
96        // Add cylinders for each bond (if bonds are available)
97        if let Some(bonds) = self.pdb.get_bonds() {
98            let coords = self.pdb.get_coords();
99            for bond in bonds.iter() {
100                let (a1, a2) = bond.get_atom_indices();
101                if let (Some(c1), Some(c2)) =
102                    (coords.get(a1 as usize), coords.get(a2 as usize))
103                {
104                    let p1 = Vec3::from_array(*c1);
105                    let p2 = Vec3::from_array(*c2);
106                    Self::append_cylinder_geometry(
107                        p1,
108                        p2,
109                        BOND_RADIUS,
110                        &mut positions,
111                        &mut normals,
112                        &mut uvs,
113                        &mut indices,
114                    );
115                }
116            }
117        }
118
119        let mut mesh = Mesh::new(
120            bevy::mesh::PrimitiveTopology::TriangleList,
121            bevy::asset::RenderAssetUsages::default(),
122        );
123        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
124        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
125        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
126        mesh.insert_indices(bevy::mesh::Indices::U32(indices));
127        mesh
128    }
129
130    /// Space-filling (Solid) rendering: each atom is drawn as a sphere scaled to its
131    /// van der Waals radius. Falls back to 1.5 Å when the VdW radius is not defined
132    /// for an element.
133    ///
134    /// Produces: ATTRIBUTE_POSITION, ATTRIBUTE_NORMAL, ATTRIBUTE_UV_0; U32 indices;
135    /// TriangleList topology. No ATTRIBUTE_COLOR.
136    fn render_spheres(&self) -> Mesh {
137        const VDW_FALLBACK: f32 = 1.5;
138
139        let mut positions: Vec<[f32; 3]> = Vec::new();
140        let mut normals: Vec<[f32; 3]> = Vec::new();
141        let mut uvs: Vec<[f32; 2]> = Vec::new();
142        let mut indices: Vec<u32> = Vec::new();
143
144        for (coord, element) in self.pdb.iter_coords_and_elements() {
145            let center = Vec3::new(coord[0], coord[1], coord[2]);
146            let radius = element
147                .atomic_radius()
148                .van_der_waals
149                .map(|r| r as f32)
150                .unwrap_or(VDW_FALLBACK);
151            Self::append_sphere_geometry(
152                center,
153                radius,
154                &mut positions,
155                &mut normals,
156                &mut uvs,
157                &mut indices,
158            );
159        }
160
161        let mut mesh = Mesh::new(
162            bevy::mesh::PrimitiveTopology::TriangleList,
163            bevy::asset::RenderAssetUsages::default(),
164        );
165        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
166        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
167        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
168        mesh.insert_indices(bevy::mesh::Indices::U32(indices));
169        mesh
170    }
171
172    fn render_putty(&self) -> Mesh {
173        self.create_sphere_mesh(2.0)
174    }
175
176    /// Append UV-sphere geometry for a single sphere into the provided vertex buffers.
177    ///
178    /// Uses 8 latitude and 8 longitude subdivisions. The base vertex index for the
179    /// generated indices is derived from the current length of `positions`.
180    fn append_sphere_geometry(
181        center: Vec3,
182        radius: f32,
183        positions: &mut Vec<[f32; 3]>,
184        normals: &mut Vec<[f32; 3]>,
185        uvs: &mut Vec<[f32; 2]>,
186        indices: &mut Vec<u32>,
187    ) {
188        let subdivisions: u32 = 8;
189        let base_index = positions.len() as u32;
190
191        for lat in 0..=subdivisions {
192            let theta = lat as f32 * std::f32::consts::PI / subdivisions as f32;
193            let sin_theta = theta.sin();
194            let cos_theta = theta.cos();
195
196            for lon in 0..=subdivisions {
197                let phi = lon as f32 * 2.0 * std::f32::consts::PI / subdivisions as f32;
198                let x = sin_theta * phi.cos();
199                let y = cos_theta;
200                let z = sin_theta * phi.sin();
201
202                let normal = Vec3::new(x, y, z);
203                let pos = center + normal * radius;
204
205                positions.push([pos.x, pos.y, pos.z]);
206                normals.push([normal.x, normal.y, normal.z]);
207                uvs.push([
208                    lon as f32 / subdivisions as f32,
209                    lat as f32 / subdivisions as f32,
210                ]);
211            }
212        }
213
214        for lat in 0..subdivisions {
215            for lon in 0..subdivisions {
216                let first = base_index + lat * (subdivisions + 1) + lon;
217                let second = first + subdivisions + 1;
218
219                indices.push(first);
220                indices.push(second);
221                indices.push(first + 1);
222
223                indices.push(second);
224                indices.push(second + 1);
225                indices.push(first + 1);
226            }
227        }
228    }
229
230    /// Append cylinder geometry connecting `p1` to `p2` into the provided vertex buffers.
231    ///
232    /// The cylinder is a closed tube (no end caps) with `radius`. Normals point radially
233    /// outward from the cylinder axis. UVs are a simple cylindrical projection. Produces
234    /// TriangleList triangles with U32 indices compatible with `append_sphere_geometry`.
235    fn append_cylinder_geometry(
236        p1: Vec3,
237        p2: Vec3,
238        radius: f32,
239        positions: &mut Vec<[f32; 3]>,
240        normals: &mut Vec<[f32; 3]>,
241        uvs: &mut Vec<[f32; 2]>,
242        indices: &mut Vec<u32>,
243    ) {
244        let direction = p2 - p1;
245        let length = direction.length();
246        if length < 1e-6 {
247            return;
248        }
249        let axis = direction.normalize();
250
251        // Build an orthonormal frame (axis, right, up)
252        let right = if axis.abs_diff_eq(Vec3::Y, 0.01) {
253            axis.cross(Vec3::Z).normalize()
254        } else {
255            axis.cross(Vec3::Y).normalize()
256        };
257        let up = axis.cross(right).normalize();
258
259        const SEGMENTS: u32 = 8;
260        let base_index = positions.len() as u32;
261
262        // Two rings: one at p1 (ring 0) and one at p2 (ring 1)
263        for ring in 0..=1u32 {
264            let center = if ring == 0 { p1 } else { p2 };
265            let v = ring as f32; // UV v coordinate (0.0 or 1.0)
266
267            for seg in 0..=SEGMENTS {
268                let angle = seg as f32 * 2.0 * std::f32::consts::PI / SEGMENTS as f32;
269                let normal = (right * angle.cos() + up * angle.sin()).normalize();
270                let pos = center + normal * radius;
271
272                positions.push([pos.x, pos.y, pos.z]);
273                normals.push([normal.x, normal.y, normal.z]);
274                uvs.push([seg as f32 / SEGMENTS as f32, v]);
275            }
276        }
277
278        // Connect the two rings with quads (two triangles each)
279        let ring_verts = SEGMENTS + 1;
280        for seg in 0..SEGMENTS {
281            let i00 = base_index + seg;
282            let i01 = base_index + seg + 1;
283            let i10 = base_index + ring_verts + seg;
284            let i11 = base_index + ring_verts + seg + 1;
285
286            indices.push(i00);
287            indices.push(i10);
288            indices.push(i01);
289
290            indices.push(i01);
291            indices.push(i10);
292            indices.push(i11);
293        }
294    }
295
296    /// Create a mesh with spheres at each atom position using a uniform radius.
297    ///
298    /// Produces: ATTRIBUTE_POSITION, ATTRIBUTE_NORMAL, ATTRIBUTE_UV_0; U32 indices;
299    /// TriangleList topology. No ATTRIBUTE_COLOR.
300    fn create_sphere_mesh(&self, radius: f32) -> Mesh {
301        let mut positions = Vec::new();
302        let mut normals = Vec::new();
303        let mut uvs = Vec::new();
304        let mut indices = Vec::new();
305
306        let subdivisions = 8;
307
308        for idx in 0..self.pdb.get_size() {
309            let coord = self.pdb.get_coord(idx);
310            let center = Vec3::new(coord[0], coord[1], coord[2]);
311            let base_index = positions.len() as u32;
312
313            // Generate a sphere using UV sphere algorithm
314            for lat in 0..=subdivisions {
315                let theta = lat as f32 * std::f32::consts::PI / subdivisions as f32;
316                let sin_theta = theta.sin();
317                let cos_theta = theta.cos();
318
319                for lon in 0..=subdivisions {
320                    let phi = lon as f32 * 2.0 * std::f32::consts::PI / subdivisions as f32;
321                    let sin_phi = phi.sin();
322                    let cos_phi = phi.cos();
323
324                    let x = sin_theta * cos_phi;
325                    let y = cos_theta;
326                    let z = sin_theta * sin_phi;
327
328                    let normal = Vec3::new(x, y, z);
329                    let pos = center + normal * radius;
330
331                    positions.push([pos.x, pos.y, pos.z]);
332                    normals.push([normal.x, normal.y, normal.z]);
333                    uvs.push([
334                        lon as f32 / subdivisions as f32,
335                        lat as f32 / subdivisions as f32,
336                    ]);
337                }
338            }
339
340            // Generate indices for the sphere
341            for lat in 0..subdivisions {
342                for lon in 0..subdivisions {
343                    let first = base_index + lat * (subdivisions + 1) + lon;
344                    let second = first + subdivisions + 1;
345
346                    indices.push(first);
347                    indices.push(second);
348                    indices.push(first + 1);
349
350                    indices.push(second);
351                    indices.push(second + 1);
352                    indices.push(first + 1);
353                }
354            }
355        }
356
357        // Create mesh with proper vertex attributes
358        let mut mesh = Mesh::new(
359            bevy::mesh::PrimitiveTopology::TriangleList,
360            bevy::asset::RenderAssetUsages::default(),
361        );
362
363        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
364        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
365        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
366        mesh.insert_indices(bevy::mesh::Indices::U32(indices));
367
368        mesh
369    }
370}
371
372// Mesh attribute contract for ferritin-structure-mesh renderers
373//
374// All render_* methods produce:
375//   ATTRIBUTE_POSITION  — Float32x3, one entry per vertex
376//   ATTRIBUTE_NORMAL    — Float32x3, outward normals (sphere or cylinder)
377//   ATTRIBUTE_UV_0      — Float32x2, lat/lon UV coordinates
378//   Indices             — U32 format, TriangleList topology
379//   ATTRIBUTE_COLOR     — NOT populated by any method in this crate
380//
381// Topology: TriangleList
382// Index format: U32
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use bevy::mesh::Indices;
388    use bevy::render::mesh::VertexAttributeValues;
389    use ferritin_core::load_structure;
390    use ferritin_test_data::TestFile;
391
392    fn load_test_structure() -> anyhow::Result<Structure> {
393        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
394        let ac = load_structure(molfile)?;
395        Ok(Structure::builder().pdb(ac).build())
396    }
397
398    fn assert_mesh_has_required_attributes(mesh: &Mesh, label: &str) {
399        assert!(
400            mesh.attribute(Mesh::ATTRIBUTE_POSITION).is_some(),
401            "{label}: missing ATTRIBUTE_POSITION"
402        );
403        assert!(
404            mesh.attribute(Mesh::ATTRIBUTE_NORMAL).is_some(),
405            "{label}: missing ATTRIBUTE_NORMAL"
406        );
407        assert!(
408            mesh.attribute(Mesh::ATTRIBUTE_UV_0).is_some(),
409            "{label}: missing ATTRIBUTE_UV_0"
410        );
411        assert!(
412            mesh.indices().is_some(),
413            "{label}: missing indices"
414        );
415    }
416
417    fn assert_indices_are_u32(mesh: &Mesh, label: &str) {
418        match mesh.indices() {
419            Some(Indices::U32(_)) => {}
420            Some(Indices::U16(_)) => panic!("{label}: expected U32 indices, got U16"),
421            None => panic!("{label}: no indices"),
422        }
423    }
424
425    fn assert_no_color_attribute(mesh: &Mesh, label: &str) {
426        assert!(
427            mesh.attribute(Mesh::ATTRIBUTE_COLOR).is_none(),
428            "{label}: ATTRIBUTE_COLOR should not be populated"
429        );
430    }
431
432    fn assert_positions_are_float32x3(mesh: &Mesh, label: &str) {
433        match mesh.attribute(Mesh::ATTRIBUTE_POSITION) {
434            Some(VertexAttributeValues::Float32x3(_)) => {}
435            other => panic!("{label}: ATTRIBUTE_POSITION is {other:?}, expected Float32x3"),
436        }
437    }
438
439    fn assert_normals_are_float32x3(mesh: &Mesh, label: &str) {
440        match mesh.attribute(Mesh::ATTRIBUTE_NORMAL) {
441            Some(VertexAttributeValues::Float32x3(_)) => {}
442            other => panic!("{label}: ATTRIBUTE_NORMAL is {other:?}, expected Float32x3"),
443        }
444    }
445
446    fn assert_uvs_are_float32x2(mesh: &Mesh, label: &str) {
447        match mesh.attribute(Mesh::ATTRIBUTE_UV_0) {
448            Some(VertexAttributeValues::Float32x2(_)) => {}
449            other => panic!("{label}: ATTRIBUTE_UV_0 is {other:?}, expected Float32x2"),
450        }
451    }
452
453    #[test]
454    fn test_render_solid_attributes() -> anyhow::Result<()> {
455        let s = load_test_structure()?;
456        let mesh = s.to_mesh(); // Solid is the default
457        assert_mesh_has_required_attributes(&mesh, "render_solid");
458        assert_indices_are_u32(&mesh, "render_solid");
459        assert_positions_are_float32x3(&mesh, "render_solid");
460        assert_normals_are_float32x3(&mesh, "render_solid");
461        assert_uvs_are_float32x2(&mesh, "render_solid");
462        assert_no_color_attribute(&mesh, "render_solid");
463        assert!(mesh.count_vertices() > 0, "render_solid: no vertices");
464        Ok(())
465    }
466
467    #[test]
468    fn test_render_wireframe_attributes() -> anyhow::Result<()> {
469        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
470        let ac = load_structure(molfile)?;
471        let s = Structure::builder().pdb(ac).rendertype(RenderOptions::Wireframe).build();
472        let mesh = s.to_mesh();
473        assert_mesh_has_required_attributes(&mesh, "render_wireframe");
474        assert_indices_are_u32(&mesh, "render_wireframe");
475        assert_positions_are_float32x3(&mesh, "render_wireframe");
476        assert_normals_are_float32x3(&mesh, "render_wireframe");
477        assert_uvs_are_float32x2(&mesh, "render_wireframe");
478        assert_no_color_attribute(&mesh, "render_wireframe");
479        Ok(())
480    }
481
482    #[test]
483    fn test_render_cartoon_attributes() -> anyhow::Result<()> {
484        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
485        let ac = load_structure(molfile)?;
486        let s = Structure::builder().pdb(ac).rendertype(RenderOptions::Cartoon).build();
487        let mesh = s.to_mesh();
488        assert_mesh_has_required_attributes(&mesh, "render_cartoon");
489        assert_indices_are_u32(&mesh, "render_cartoon");
490        assert_positions_are_float32x3(&mesh, "render_cartoon");
491        assert_normals_are_float32x3(&mesh, "render_cartoon");
492        assert_uvs_are_float32x2(&mesh, "render_cartoon");
493        assert_no_color_attribute(&mesh, "render_cartoon");
494        Ok(())
495    }
496
497    #[test]
498    fn test_render_ballandstick_attributes() -> anyhow::Result<()> {
499        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
500        let ac = load_structure(molfile)?;
501        let s = Structure::builder().pdb(ac).rendertype(RenderOptions::BallAndStick).build();
502        let mesh = s.to_mesh();
503        assert_mesh_has_required_attributes(&mesh, "render_ballandstick");
504        assert_indices_are_u32(&mesh, "render_ballandstick");
505        assert_positions_are_float32x3(&mesh, "render_ballandstick");
506        assert_normals_are_float32x3(&mesh, "render_ballandstick");
507        assert_uvs_are_float32x2(&mesh, "render_ballandstick");
508        assert_no_color_attribute(&mesh, "render_ballandstick");
509        Ok(())
510    }
511
512    #[test]
513    fn test_render_putty_attributes() -> anyhow::Result<()> {
514        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
515        let ac = load_structure(molfile)?;
516        let s = Structure::builder().pdb(ac).rendertype(RenderOptions::Putty).build();
517        let mesh = s.to_mesh();
518        assert_mesh_has_required_attributes(&mesh, "render_putty");
519        assert_indices_are_u32(&mesh, "render_putty");
520        assert_positions_are_float32x3(&mesh, "render_putty");
521        assert_normals_are_float32x3(&mesh, "render_putty");
522        assert_uvs_are_float32x2(&mesh, "render_putty");
523        assert_no_color_attribute(&mesh, "render_putty");
524        Ok(())
525    }
526
527    #[test]
528    fn test_vertex_and_index_counts_are_consistent() -> anyhow::Result<()> {
529        let s = load_test_structure()?;
530        let mesh = s.to_mesh();
531        let n_verts = mesh.count_vertices();
532        assert!(n_verts > 0, "mesh has no vertices");
533        if let Some(Indices::U32(idx)) = mesh.indices() {
534            assert!(!idx.is_empty(), "mesh has no indices");
535            assert!(
536                idx.iter().all(|&i| (i as usize) < n_verts),
537                "index out of bounds"
538            );
539        }
540        Ok(())
541    }
542
543    #[test]
544    fn test_render_ballandstick_has_more_verts_than_solid() -> anyhow::Result<()> {
545        // BallAndStick (radius 0.4 spheres + bond cylinders) should produce more
546        // vertices than a Solid (VdW radius spheres, no cylinders) for the same structure,
547        // because bond cylinders add geometry on top of the atom spheres.
548        //
549        // Both modes use 8×8 UV sphere subdivisions per atom (81 vertices each).
550        // BallAndStick adds 9 vertices per ring × 2 rings per bond cylinder.
551        //
552        // If bonds exist the BallAndStick mesh will be strictly larger.
553        let (molfile, _handle) = TestFile::protein_01().create_temp()?;
554        let ac = load_structure(molfile)?;
555        let n_atoms = ac.get_size();
556        let has_bonds = ac.get_bonds().map(|b| !b.is_empty()).unwrap_or(false);
557
558        let s = Structure::builder()
559            .pdb(ac)
560            .rendertype(RenderOptions::BallAndStick)
561            .build();
562        let mesh = s.to_mesh();
563
564        // Each atom contributes (8+1)*(8+1) = 81 vertices for the sphere
565        let sphere_only_verts = n_atoms * 81;
566        assert!(
567            mesh.count_vertices() >= sphere_only_verts,
568            "expected at least {} vertices from atom spheres, got {}",
569            sphere_only_verts,
570            mesh.count_vertices(),
571        );
572
573        if has_bonds {
574            assert!(
575                mesh.count_vertices() > sphere_only_verts,
576                "expected bond cylinder vertices on top of atom spheres: total={}, spheres_only={}",
577                mesh.count_vertices(),
578                sphere_only_verts,
579            );
580        }
581        Ok(())
582    }
583
584    #[test]
585    fn test_render_solid_uses_vdw_radii() -> anyhow::Result<()> {
586        // Solid mode uses per-element VdW radii, so the vertex positions should differ
587        // from a uniform-radius sphere mesh (radius 1.5) only when VdW radii vary.
588        // At minimum, verify the mesh has vertices and passes attribute checks.
589        let s = load_test_structure()?;
590        let mesh = s.to_mesh();
591        assert!(mesh.count_vertices() > 0, "render_solid: no vertices");
592        Ok(())
593    }
594}