1use 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#[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
41struct BackboneAtoms {
43 ca: Vec3,
44 n: Vec3,
45 c: Vec3,
46}
47
48#[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 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 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 pub fn get_material(&self) -> StandardMaterial {
99 self.material.clone()
100 }
101
102 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 const CA_BREAK_DIST: f32 = 5.0;
121
122 const BALL_RADIUS: f32 = 0.3;
126 const STICK_RADIUS: f32 = 0.15;
127
128 const LINE_RADIUS: f32 = 0.13;
135
136 const TUBE_CROSS_SEGMENTS: usize = 20;
142 const TUBE_CURVE_SEGMENTS: usize = 6;
143
144 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 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 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 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 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 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 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 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 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 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 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 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 let frames = Structure::sweep_frames(curve);
436 for (i, ¢er) in curve.iter().enumerate() {
438 let radius = radii[i];
439 let (right, up) = frames[i];
440 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 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 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 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 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 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 let segments = Self::TUBE_CROSS_SEGMENTS;
546
547 let frames = Structure::sweep_frames_oriented(curve, target_up);
551
552 for (i, ¢er) 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 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 (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 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 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 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 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 self.render_putty_mapped(None).0
780 }
781
782 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1201 #[allow(clippy::assertions_on_constants)] 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 #[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)] 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 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 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 #[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 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 Ok(())
1419 }
1420
1421 #[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); assert!(!Structure::is_backbone_break(0, "A", a, 1, "A", b));
1429 assert!(Structure::is_backbone_break(0, "A", a, 1, "B", b));
1431 assert!(Structure::is_backbone_break(0, "A", a, 2, "A", b));
1433 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 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), 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 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 #[test]
1472 fn test_peptide_plane_normals_sign_corrected_and_unit() {
1473 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 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 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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}