1use std::path::PathBuf;
24
25use bevy::anti_alias::fxaa::Fxaa;
26use bevy::asset::AssetPlugin;
27use bevy::core_pipeline::tonemapping::{DebandDither, Tonemapping};
28use bevy::pbr::{ScreenSpaceAmbientOcclusion, ScreenSpaceAmbientOcclusionQualityLevel};
29use bevy::prelude::*;
30use bevy::ui::Node as UiNode;
31use ferritin_core::{AtomCollection, Model, load_model};
32use ferritin_molviewspec::molviewspec::nodes::{
33 CameraParams, CanvasParams, ColorT, ColorThemeT, ComponentInlineParams, ComponentSelector,
34 FocusInlineParams, KindT, LabelInlineParams, LineParams, Node, NodeParams, ParseFormatT,
35 RepresentationParams, RepresentationTypeT, SphereParams, State, StructureParams,
36 StructureTypeT, TransformParams,
37};
38
39use crate::colors::{VertexMapKind, apply_mvs_colors_with_theme, color_t_to_bevy};
40use crate::selection::{AtomMask, evaluate_selector};
41use crate::structure::{RenderOptions, Structure};
42
43#[derive(Component)]
51pub struct MvsEntity;
52
53#[derive(Component)]
57struct MvsRetiredEntity {
58 remaining_frames: u8,
59}
60
61#[derive(Component)]
65pub struct MvsLabel(pub String);
66
67#[derive(Component)]
69struct MvsLabelUiNode(Entity);
70
71#[derive(Resource, Default)]
74pub struct MvsStateResource(pub Option<State>);
75
76#[derive(Resource, Clone, Debug, PartialEq)]
80pub struct OrbitCamera {
81 pub focus: Vec3,
82 pub radius: f32,
83 pub yaw: f32,
84 pub pitch: f32,
85 pub up: Vec3,
86}
87
88impl Default for OrbitCamera {
89 fn default() -> Self {
90 let radius = 50.0_f32.hypot(100.0);
91 Self {
92 focus: Vec3::ZERO,
93 radius,
94 yaw: 0.0,
95 pitch: (50.0 / radius).asin(),
96 up: Vec3::Y,
97 }
98 }
99}
100
101#[derive(Message, Clone, Debug)]
103pub enum LoadMvsEvent {
104 FromFile(PathBuf),
106 FromString(String),
108}
109
110#[derive(Message, Clone, Debug, PartialEq)]
113pub enum MvsError {
114 FileNotFound(PathBuf),
116 ParseError(String),
118 NoAtomsSelected { selector_description: String },
120 InvalidTransform,
122 UnsupportedNode { kind: KindT, reason: String },
124 RepresentationDegraded {
128 requested: RepresentationTypeT,
129 rendered_as: &'static str,
130 },
131}
132
133pub struct MvsPlugin;
143
144impl Plugin for MvsPlugin {
145 fn build(&self, app: &mut App) {
146 if !app.is_plugin_added::<AssetPlugin>() {
147 app.add_plugins(AssetPlugin::default());
148 }
149 if !app.world().contains_resource::<Assets<Mesh>>() {
150 app.init_asset::<Mesh>();
151 }
152 if !app.world().contains_resource::<Assets<StandardMaterial>>() {
153 app.init_asset::<StandardMaterial>();
154 }
155 if !app.world().contains_resource::<ClearColor>() {
156 app.insert_resource(ClearColor::default());
157 }
158 app.init_resource::<MvsStateResource>()
159 .init_resource::<OrbitCamera>()
160 .add_message::<LoadMvsEvent>()
161 .add_message::<MvsError>()
162 .add_systems(
163 Update,
164 (
165 execute_mvs_on_load,
166 update_mvs_label_billboards,
167 apply_molecular_camera_defaults,
168 despawn_retired_mvs_entities,
169 )
170 .chain(),
171 );
172 }
173}
174
175fn apply_molecular_camera_defaults(
183 mut commands: Commands,
184 cameras: Query<Entity, Added<Camera3d>>,
185) {
186 for entity in &cameras {
187 commands.entity(entity).insert((
188 Tonemapping::None,
189 DebandDither::Disabled,
191 Msaa::Off,
192 Fxaa::default(),
193 ScreenSpaceAmbientOcclusion {
194 quality_level: ScreenSpaceAmbientOcclusionQualityLevel::High,
195 constant_object_thickness: 0.25,
196 ..Default::default()
197 },
198 ));
199 }
200}
201
202fn despawn_retired_mvs_entities(
203 mut commands: Commands,
204 mut retired: Query<(Entity, &mut MvsRetiredEntity)>,
205) {
206 for (entity, mut retired) in &mut retired {
207 if retired.remaining_frames == 0 {
208 commands.entity(entity).despawn();
209 } else {
210 retired.remaining_frames -= 1;
211 }
212 }
213}
214
215fn update_mvs_label_billboards(
221 mut commands: Commands,
222 camera_q: Query<(&Camera, &GlobalTransform), With<Camera3d>>,
223 label_q: Query<(Entity, &Transform, Option<&MvsLabelUiNode>), With<MvsLabel>>,
224 mut node_q: Query<&mut UiNode>,
225 text_q: Query<&MvsLabel>,
226) {
227 let Ok((camera, camera_transform)) = camera_q.single() else {
228 return;
229 };
230
231 for (entity, transform, ui_node) in &label_q {
232 let viewport_pos = camera
233 .world_to_viewport(camera_transform, transform.translation)
234 .ok();
235
236 match (ui_node, viewport_pos) {
237 (Some(MvsLabelUiNode(ui_entity)), Some(pos)) => {
238 if let Ok(mut node) = node_q.get_mut(*ui_entity) {
239 node.display = Display::Flex;
240 node.left = Val::Px(pos.x);
241 node.top = Val::Px(pos.y);
242 }
243 }
244 (Some(MvsLabelUiNode(ui_entity)), None) => {
245 if let Ok(mut node) = node_q.get_mut(*ui_entity) {
246 node.display = Display::None;
247 }
248 }
249 (None, Some(pos)) => {
250 let Ok(label) = text_q.get(entity) else {
251 continue;
252 };
253 let ui_entity = commands
254 .spawn((
255 Text::new(label.0.clone()),
256 TextColor(Color::WHITE),
257 UiNode {
258 position_type: PositionType::Absolute,
259 left: Val::Px(pos.x),
260 top: Val::Px(pos.y),
261 ..default()
262 },
263 MvsEntity,
264 ))
265 .id();
266 commands.entity(entity).insert(MvsLabelUiNode(ui_entity));
267 }
268 (None, None) => {}
269 }
270 }
271}
272
273struct Ctx<'a, 'w, 's> {
281 commands: &'a mut Commands<'w, 's>,
282 meshes: &'a mut Assets<Mesh>,
283 materials: &'a mut Assets<StandardMaterial>,
284 clear: &'a mut ClearColor,
285 orbit: &'a mut OrbitCamera,
286 errors: Vec<MvsError>,
287 focus_union: Option<(Vec3, Vec3)>,
291 scene_bounds: Option<(Vec3, Vec3)>,
297 aspect_ratio: f32,
303}
304
305#[allow(clippy::too_many_arguments)]
307fn execute_mvs_on_load(
308 mut load_events: MessageReader<LoadMvsEvent>,
309 mut error_writer: MessageWriter<MvsError>,
310 existing: Query<Entity, With<MvsEntity>>,
311 camera_q: Query<&Camera, With<Camera3d>>,
312 mut commands: Commands,
313 mut meshes: ResMut<Assets<Mesh>>,
314 mut materials: ResMut<Assets<StandardMaterial>>,
315 mut clear: ResMut<ClearColor>,
316 mut orbit: ResMut<OrbitCamera>,
317 mut state_res: ResMut<MvsStateResource>,
318) {
319 let aspect_ratio = camera_q
323 .single()
324 .ok()
325 .and_then(|c| c.logical_viewport_size())
326 .filter(|size| size.y > 0.0)
327 .map(|size| size.x / size.y)
328 .unwrap_or(16.0 / 9.0);
329
330 for event in load_events.read() {
331 let state = match parse_state(event) {
333 Ok(s) => s,
334 Err(e) => {
335 error_writer.write(e);
336 continue;
337 }
338 };
339
340 for entity in existing.iter() {
345 commands.entity(entity).insert((
346 Visibility::Hidden,
347 MvsRetiredEntity {
348 remaining_frames: 1,
349 },
350 ));
351 }
352
353 let mut ctx = Ctx {
354 commands: &mut commands,
355 meshes: &mut meshes,
356 materials: &mut materials,
357 clear: &mut clear,
358 orbit: &mut orbit,
359 errors: Vec::new(),
360 focus_union: None,
361 scene_bounds: None,
362 aspect_ratio,
363 };
364
365 execute_root(&state.root, &mut ctx);
366
367 for err in ctx.errors {
368 error_writer.write(err);
369 }
370
371 state_res.0 = Some(state);
373 }
374}
375
376fn parse_state(event: &LoadMvsEvent) -> Result<State, MvsError> {
377 match event {
378 LoadMvsEvent::FromString(s) => {
379 State::from_str(s).map_err(|e| MvsError::ParseError(e.to_string()))
380 }
381 LoadMvsEvent::FromFile(path) => {
382 let text = std::fs::read_to_string(path)
383 .map_err(|e| MvsError::ParseError(format!("{}: {e}", path.display())))?;
384 State::from_str(&text).map_err(|e| MvsError::ParseError(e.to_string()))
385 }
386 }
387}
388
389fn children(node: &Node) -> &[Node] {
394 node.children.as_deref().unwrap_or(&[])
395}
396
397fn execute_root(root: &Node, ctx: &mut Ctx) {
398 for child in children(root) {
401 if child.kind == KindT::Canvas
402 && let Some(NodeParams::CanvasParams(p)) = &child.params
403 {
404 apply_canvas(p, ctx);
405 }
406 }
407 for child in children(root) {
408 if child.kind == KindT::Camera
409 && let Some(NodeParams::CameraParams(p)) = &child.params
410 {
411 apply_camera(p, ctx);
412 }
413 }
414
415 for child in children(root) {
416 match child.kind {
417 KindT::GenericVisuals => execute_generic_visuals(child, ctx),
418 KindT::Download => execute_download(child, ctx),
419 _ => {}
420 }
421 }
422}
423
424fn execute_download(node: &Node, ctx: &mut Ctx) {
425 let url = match &node.params {
426 Some(NodeParams::DownloadParams(p)) => p.url.clone(),
427 _ => return,
428 };
429
430 if is_http_url(&url) {
431 ctx.errors.push(MvsError::UnsupportedNode {
432 kind: KindT::Download,
433 reason: format!("HTTP(S) downloads are not supported in this MVP: {url}"),
434 });
435 return;
436 }
437
438 let path = PathBuf::from(&url);
439 let model = match load_model(&path) {
440 Ok(m) => m,
441 Err(_) => {
442 ctx.errors.push(MvsError::FileNotFound(path));
443 return;
444 }
445 };
446 let ac = AtomCollection::from(&model);
447
448 for parse in children(node) {
450 if parse.kind != KindT::Parse {
451 continue;
452 }
453 if let Some(NodeParams::ParseParams(pp)) = &parse.params
455 && matches!(pp.format, ParseFormatT::Bcif)
456 {
457 ctx.errors.push(MvsError::UnsupportedNode {
458 kind: KindT::Parse,
459 reason: "bcif parsing is not supported".to_string(),
460 });
461 }
462 for structure in children(parse) {
463 if structure.kind == KindT::Structure {
464 execute_structure(structure, &model, &ac, ctx);
465 }
466 }
467 }
468}
469
470fn execute_structure(node: &Node, model: &Model, ac: &AtomCollection, ctx: &mut Ctx) {
471 if let Some(NodeParams::StructureParams(sp)) = &node.params {
473 report_unsupported_structure(sp, ctx);
474 }
475
476 let mut pending_transform = Transform::IDENTITY;
479 for child in children(node) {
480 if child.kind == KindT::Transform
481 && let Some(NodeParams::TransformParams(tp)) = &child.params
482 {
483 match parse_transform(tp) {
484 Ok(t) => pending_transform = t,
485 Err(()) => ctx.errors.push(MvsError::InvalidTransform),
486 }
487 }
488 }
489
490 for child in children(node) {
491 match child.kind {
492 KindT::Component => execute_component(child, model, ac, pending_transform, ctx),
493 KindT::ComponentFromUri | KindT::ComponentFromSource => {
494 ctx.errors.push(MvsError::UnsupportedNode {
495 kind: child.kind.clone(),
496 reason: "annotation-driven components are not supported".to_string(),
497 });
498 }
499 KindT::LabelFromUri
500 | KindT::LabelFromSource
501 | KindT::TooltipFromUri
502 | KindT::TooltipFromSource => {
503 ctx.errors.push(MvsError::UnsupportedNode {
504 kind: child.kind.clone(),
505 reason: "annotation-driven labels/tooltips are not supported".to_string(),
506 });
507 }
508 _ => {}
509 }
510 }
511}
512
513fn execute_component(
514 node: &Node,
515 model: &Model,
516 ac: &AtomCollection,
517 transform: Transform,
518 ctx: &mut Ctx,
519) {
520 let selector = match &node.params {
521 Some(NodeParams::ComponentInlineParams(ComponentInlineParams { selector })) => selector,
522 _ => return,
523 };
524
525 let mask = evaluate_selector(selector, ac);
526 if mask.all_false() {
527 ctx.errors.push(MvsError::NoAtomsSelected {
528 selector_description: describe_selector(selector),
529 });
530 return;
531 }
532
533 for child in children(node) {
535 match child.kind {
536 KindT::Representation => render_representation(child, model, ac, &mask, transform, ctx),
537 KindT::Label => {
538 if let Some(NodeParams::LabelInlineParams(LabelInlineParams { text })) =
539 &child.params
540 {
541 let centroid = centroid_of_masked(ac, &mask).unwrap_or(Vec3::ZERO);
542 ctx.commands.spawn((
543 MvsLabel(text.clone()),
544 Transform::from_translation(centroid),
545 MvsEntity,
546 ));
547 }
548 }
549 KindT::Focus => {
550 let params = match &child.params {
551 Some(NodeParams::FocusInlineParams(p)) => p.clone(),
552 _ => FocusInlineParams {
553 direction: None,
554 up: None,
555 },
556 };
557 apply_focus(¶ms, ac, &mask, transform, ctx);
558 }
559 _ => {}
560 }
561 }
562}
563
564fn render_representation(
565 repr: &Node,
566 model: &Model,
567 ac: &AtomCollection,
568 mask: &AtomMask,
569 transform: Transform,
570 ctx: &mut Ctx,
571) {
572 if let Some((local_min, local_max)) = aabb_of_masked(ac, mask) {
576 let (world_min, world_max) = transform_aabb(local_min, local_max, transform);
577 ctx.scene_bounds = Some(match ctx.scene_bounds {
578 Some((prev_min, prev_max)) => (prev_min.min(world_min), prev_max.max(world_max)),
579 None => (world_min, world_max),
580 });
581 }
582
583 let (repr_type, color_theme) = match &repr.params {
584 Some(NodeParams::RepresentationParams(RepresentationParams {
585 representation_type,
586 color_theme,
587 })) => (representation_type.clone(), color_theme.clone()),
588 _ => return,
589 };
590
591 let (render_opt, degraded) = map_representation(&repr_type);
592 if degraded {
593 warn!("Surface representation is not supported; rendering as VdW spheres");
594 ctx.errors.push(MvsError::RepresentationDegraded {
595 requested: repr_type.clone(),
596 rendered_as: "VdW spheres (Spacefill)",
597 });
598 }
599
600 let mut color_nodes: Vec<(ComponentSelector, ColorT)> = Vec::new();
602 for child in children(repr) {
603 match child.kind {
604 KindT::Color => {
605 if let Some(NodeParams::ColorInlineParams(cp)) = &child.params {
606 color_nodes.push((cp.base.selector.clone(), cp.color.clone()));
607 }
608 }
609 KindT::ColorFromUri | KindT::ColorFromSource => {
610 ctx.errors.push(MvsError::UnsupportedNode {
611 kind: child.kind.clone(),
612 reason: "annotation-driven coloring is not supported".to_string(),
613 });
614 }
615 _ => {}
616 }
617 }
618
619 let structure = Structure::builder()
620 .pdb(model.clone())
621 .rendertype(render_opt.clone())
622 .build();
623
624 let (mut mesh, vertex_map, map_kind) = match render_opt {
625 RenderOptions::Cartoon | RenderOptions::Putty => {
626 let (m, v) = structure.to_mesh_with_residue_map(Some(mask));
627 (m, v, VertexMapKind::ResidueMap)
628 }
629 _ => {
630 let (m, v) = structure.to_mesh_with_atom_map(Some(mask));
631 (m, v, VertexMapKind::AtomMap)
632 }
633 };
634
635 let effective_theme = color_theme.unwrap_or(ColorThemeT::ElementSymbol);
638 apply_mvs_colors_with_theme(
639 &mut mesh,
640 &effective_theme,
641 &color_nodes,
642 ac,
643 &vertex_map,
644 map_kind,
645 );
646
647 let mut material = StandardMaterial::default();
651 if degraded {
655 material.base_color = Color::srgb(1.0, 0.78, 0.5);
656 material.emissive = LinearRgba::rgb(0.15, 0.08, 0.0);
657 }
658
659 ctx.commands.spawn((
660 Mesh3d(ctx.meshes.add(mesh)),
661 MeshMaterial3d(ctx.materials.add(material)),
662 transform,
663 MvsEntity,
664 ));
665}
666
667fn execute_generic_visuals(node: &Node, ctx: &mut Ctx) {
668 for child in children(node) {
669 match child.kind {
670 KindT::Sphere => {
671 if let Some(NodeParams::SphereParams(p)) = &child.params {
672 spawn_sphere(p, ctx);
673 }
674 }
675 KindT::Line => {
676 if let Some(NodeParams::LineParams(p)) = &child.params {
677 spawn_line(p, ctx);
678 }
679 }
680 _ => {}
681 }
682 }
683}
684
685fn spawn_sphere(p: &SphereParams, ctx: &mut Ctx) {
686 let center = tuple_to_vec3(p.position);
687 let mesh = Sphere::new(p.radius as f32).mesh().build();
688 let material = StandardMaterial {
689 base_color: color_t_to_bevy(&p.color),
690 ..default()
691 };
692 ctx.commands.spawn((
693 Mesh3d(ctx.meshes.add(mesh)),
694 MeshMaterial3d(ctx.materials.add(material)),
695 Transform::from_translation(center),
696 MvsEntity,
697 ));
698}
699
700fn spawn_line(p: &LineParams, ctx: &mut Ctx) {
701 let p1 = tuple_to_vec3(p.position1);
702 let p2 = tuple_to_vec3(p.position2);
703 let center = (p1 + p2) * 0.5;
704 let direction = p2 - p1;
705 let height = direction.length().max(1e-6);
706 let rotation = Quat::from_rotation_arc(Vec3::Y, direction.normalize_or_zero());
707 let mesh = Cylinder {
708 radius: p.radius as f32,
709 half_height: height * 0.5,
710 }
711 .mesh()
712 .build();
713 let material = StandardMaterial {
714 base_color: color_t_to_bevy(&p.color),
715 ..default()
716 };
717 ctx.commands.spawn((
718 Mesh3d(ctx.meshes.add(mesh)),
719 MeshMaterial3d(ctx.materials.add(material)),
720 Transform {
721 translation: center,
722 rotation,
723 ..default()
724 },
725 MvsEntity,
726 ));
727}
728
729fn apply_canvas(p: &CanvasParams, ctx: &mut Ctx) {
734 *ctx.clear = ClearColor(color_t_to_bevy(&p.background_color));
735}
736
737fn apply_camera(p: &CameraParams, ctx: &mut Ctx) {
738 let target = tuple_to_vec3(p.target);
739 let position = tuple_to_vec3(p.position);
740 set_orbit_from_position(ctx.orbit, target, position);
741 if let Some(up) = p.up {
742 ctx.orbit.up = tuple_to_vec3(up);
743 }
744}
745
746fn apply_focus(
747 params: &FocusInlineParams,
748 ac: &AtomCollection,
749 mask: &AtomMask,
750 transform: Transform,
751 ctx: &mut Ctx,
752) {
753 let Some((local_min, local_max)) = aabb_of_masked(ac, mask) else {
754 return;
755 };
756 let (world_min, world_max) = transform_aabb(local_min, local_max, transform);
760
761 let (union_min, union_max) = match ctx.focus_union {
765 Some((prev_min, prev_max)) => (prev_min.min(world_min), prev_max.max(world_max)),
766 None => (world_min, world_max),
767 };
768 ctx.focus_union = Some((union_min, union_max));
769
770 ctx.orbit.focus = (union_min + union_max) * 0.5;
771
772 if let Some(up) = params.up {
773 ctx.orbit.up = tuple_to_vec3(up);
774 }
775 if let Some(dir) = params.direction {
779 let view = tuple_to_vec3(dir);
780 if view.length() > 1e-6 {
781 set_yaw_pitch_from_offset(ctx.orbit, -view.normalize());
782 }
783 }
784
785 let mut radius = fit_radius_for_view(union_min, union_max, ctx.orbit, ctx.aspect_ratio);
786
787 if let Some((scene_min, scene_max)) = ctx.scene_bounds {
796 let scene_radius = fit_radius_for_view_from_center(
797 ctx.orbit.focus,
798 scene_min,
799 scene_max,
800 ctx.orbit,
801 ctx.aspect_ratio,
802 );
803 radius = radius.max(scene_radius);
804 }
805
806 ctx.orbit.radius = radius;
807}
808
809fn transform_aabb(local_min: Vec3, local_max: Vec3, transform: Transform) -> (Vec3, Vec3) {
813 let mut world_min = Vec3::splat(f32::MAX);
814 let mut world_max = Vec3::splat(f32::MIN);
815 for &x in &[local_min.x, local_max.x] {
816 for &y in &[local_min.y, local_max.y] {
817 for &z in &[local_min.z, local_max.z] {
818 let corner = transform.transform_point(Vec3::new(x, y, z));
819 world_min = world_min.min(corner);
820 world_max = world_max.max(corner);
821 }
822 }
823 }
824 (world_min, world_max)
825}
826
827fn fit_radius_for_view(min: Vec3, max: Vec3, orbit: &OrbitCamera, aspect: f32) -> f32 {
837 let center = (min + max) * 0.5;
838 fit_radius_for_view_from_center(center, min, max, orbit, aspect)
839}
840
841fn fit_radius_for_view_from_center(
852 center: Vec3,
853 min: Vec3,
854 max: Vec3,
855 orbit: &OrbitCamera,
856 aspect: f32,
857) -> f32 {
858 let view_axis = Vec3::new(
861 orbit.yaw.sin() * orbit.pitch.cos(),
862 orbit.pitch.sin(),
863 orbit.yaw.cos() * orbit.pitch.cos(),
864 );
865 let world_up = if orbit.up.length_squared() > 1e-6 {
866 orbit.up
867 } else {
868 Vec3::Y
869 };
870 let right = view_axis.cross(world_up).normalize_or_zero();
871 let cam_up = right.cross(view_axis).normalize_or_zero();
872
873 let mut half_right = 0.0_f32;
874 let mut half_up = 0.0_f32;
875 for &x in &[min.x, max.x] {
876 for &y in &[min.y, max.y] {
877 for &z in &[min.z, max.z] {
878 let corner = Vec3::new(x, y, z) - center;
879 half_right = half_right.max(corner.dot(right).abs());
880 half_up = half_up.max(corner.dot(cam_up).abs());
881 }
882 }
883 }
884
885 const V_HALF_FOV: f32 = std::f32::consts::FRAC_PI_4 / 2.0;
890 let h_half_fov = (aspect * V_HALF_FOV.tan()).atan();
891 const MARGIN: f32 = 1.15;
892 let radius_for_up = half_up / V_HALF_FOV.tan();
893 let radius_for_right = half_right / h_half_fov.tan();
894 (radius_for_up.max(radius_for_right) * MARGIN).max(1.0)
895}
896
897fn set_orbit_from_position(orbit: &mut OrbitCamera, target: Vec3, position: Vec3) {
898 orbit.focus = target;
899 let offset = position - target;
900 let r = offset.length();
901 if r > 1e-6 {
902 orbit.radius = r;
903 set_yaw_pitch_from_offset(orbit, offset);
904 }
905}
906
907fn set_yaw_pitch_from_offset(orbit: &mut OrbitCamera, offset: Vec3) {
908 let r = offset.length();
909 if r > 1e-6 {
910 orbit.yaw = offset.x.atan2(offset.z);
911 orbit.pitch = (offset.y / r).clamp(-1.0, 1.0).asin();
912 }
913}
914
915fn is_http_url(url: &str) -> bool {
922 let u = url.trim_start();
923 u.starts_with("http://") || u.starts_with("https://")
924}
925
926fn map_representation(repr: &RepresentationTypeT) -> (RenderOptions, bool) {
929 match repr {
930 RepresentationTypeT::BallAndStick => (RenderOptions::BallAndStick, false),
931 RepresentationTypeT::Cartoon => (RenderOptions::Cartoon, false),
932 RepresentationTypeT::Line => (RenderOptions::Wireframe, false),
933 RepresentationTypeT::Putty => (RenderOptions::Putty, false),
934 RepresentationTypeT::Spacefill => (RenderOptions::Solid, false),
935 RepresentationTypeT::Surface => (RenderOptions::Solid, true),
936 }
937}
938
939fn parse_transform(params: &TransformParams) -> Result<Transform, ()> {
942 let mut transform = Transform::IDENTITY;
943 if let Some(rot) = ¶ms.rotation {
944 if rot.len() != 9 {
945 return Err(());
946 }
947 let m = Mat3::from_cols_array(&[
948 rot[0] as f32,
949 rot[1] as f32,
950 rot[2] as f32,
951 rot[3] as f32,
952 rot[4] as f32,
953 rot[5] as f32,
954 rot[6] as f32,
955 rot[7] as f32,
956 rot[8] as f32,
957 ]);
958 transform.rotation = Quat::from_mat3(&m);
959 }
960 if let Some(t) = ¶ms.translation {
961 transform.translation = tuple_to_vec3(*t);
962 }
963 Ok(transform)
964}
965
966fn aabb_of_masked(ac: &AtomCollection, mask: &AtomMask) -> Option<(Vec3, Vec3)> {
970 let mut min = Vec3::splat(f32::MAX);
971 let mut max = Vec3::splat(f32::MIN);
972 let mut any = false;
973 for (i, coord) in ac.get_coords().iter().enumerate() {
974 if !mask.0.get(i).copied().unwrap_or(false) {
975 continue;
976 }
977 any = true;
978 let p = Vec3::from_array(*coord);
979 min = min.min(p);
980 max = max.max(p);
981 }
982 if !any {
983 return None;
984 }
985 Some((min, max))
986}
987
988fn centroid_of_masked(ac: &AtomCollection, mask: &AtomMask) -> Option<Vec3> {
990 let mut sum = Vec3::ZERO;
991 let mut count = 0usize;
992 for (i, coord) in ac.get_coords().iter().enumerate() {
993 if !mask.0.get(i).copied().unwrap_or(false) {
994 continue;
995 }
996 sum += Vec3::from_array(*coord);
997 count += 1;
998 }
999 if count == 0 {
1000 None
1001 } else {
1002 Some(sum / count as f32)
1003 }
1004}
1005
1006fn report_unsupported_structure(sp: &StructureParams, ctx: &mut Ctx) {
1007 let reason = match sp.structure_type {
1008 StructureTypeT::Assembly => Some("assembly biological units are not expanded"),
1009 StructureTypeT::Symmetry | StructureTypeT::SymmetryMates => {
1010 Some("symmetry mates are not generated")
1011 }
1012 StructureTypeT::Model => None,
1013 };
1014 if let Some(reason) = reason {
1015 ctx.errors.push(MvsError::UnsupportedNode {
1016 kind: KindT::Structure,
1017 reason: format!("{reason}; rendering the asymmetric unit"),
1018 });
1019 }
1020}
1021
1022fn describe_selector(selector: &ComponentSelector) -> String {
1023 match selector {
1024 ComponentSelector::Selector(s) => format!("{s:?}"),
1025 ComponentSelector::Expression(e) => format!("{e:?}"),
1026 ComponentSelector::ExpressionList(l) => format!("expression list ({} items)", l.len()),
1027 }
1028}
1029
1030fn tuple_to_vec3(t: (f64, f64, f64)) -> Vec3 {
1031 Vec3::new(t.0 as f32, t.1 as f32, t.2 as f32)
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use bevy::prelude::Messages;
1038 use ferritin_core::info::elements::Element;
1039 use ferritin_molviewspec::molviewspec::nodes::ComponentSelectorT;
1040
1041 fn make_app() -> App {
1046 let mut app = App::new();
1047 app.add_plugins(bevy::MinimalPlugins);
1048 app.add_plugins(bevy::asset::AssetPlugin::default());
1049 app.add_plugins(MvsPlugin);
1050 app
1051 }
1052
1053 fn count_meshes(app: &mut App) -> usize {
1054 app.world_mut()
1055 .query_filtered::<Entity, (With<Mesh3d>, With<MvsEntity>, Without<MvsRetiredEntity>)>()
1056 .iter(app.world())
1057 .count()
1058 }
1059
1060 fn count_retired_meshes(app: &mut App) -> usize {
1061 app.world_mut()
1062 .query_filtered::<Entity, (With<Mesh3d>, With<MvsRetiredEntity>)>()
1063 .iter(app.world())
1064 .count()
1065 }
1066
1067 fn count_labels(app: &mut App) -> usize {
1068 app.world_mut()
1069 .query_filtered::<Entity, (With<MvsLabel>, Without<MvsRetiredEntity>)>()
1070 .iter(app.world())
1071 .count()
1072 }
1073
1074 fn get_errors(app: &App) -> Vec<MvsError> {
1075 app.world()
1076 .resource::<Messages<MvsError>>()
1077 .iter_current_update_messages()
1078 .cloned()
1079 .collect()
1080 }
1081
1082 fn send_and_update(app: &mut App, json: &str) {
1083 app.world_mut()
1084 .write_message(LoadMvsEvent::FromString(json.to_string()));
1085 app.update();
1086 }
1087
1088 fn mvsj_one_component(path: &str, selector: &str, repr: &str) -> String {
1090 format!(
1091 r#"{{
1092 "root": {{
1093 "kind": "root",
1094 "children": [{{
1095 "kind": "download",
1096 "params": {{"url": "{path}"}},
1097 "children": [{{
1098 "kind": "parse",
1099 "params": {{"format": "mmcif"}},
1100 "children": [{{
1101 "kind": "structure",
1102 "params": {{"type": "model"}},
1103 "children": [{{
1104 "kind": "component",
1105 "params": {{"selector": "{selector}"}},
1106 "children": [{{
1107 "kind": "representation",
1108 "params": {{"type": "{repr}"}}
1109 }}]
1110 }}]
1111 }}]
1112 }}]
1113 }}]
1114 }},
1115 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1116 }}"#
1117 )
1118 }
1119
1120 #[test]
1123 fn test_url_is_http_https() {
1124 assert!(is_http_url("https://files.wwpdb.org/download/1cbs.cif"));
1125 assert!(is_http_url("http://example.com/foo.cif"));
1126 }
1127
1128 #[test]
1129 fn test_url_is_local() {
1130 assert!(!is_http_url("data/structures/1cbs.cif"));
1131 assert!(!is_http_url("/absolute/path/1cbs.cif"));
1132 assert!(!is_http_url("./relative/1cbs.cif"));
1133 }
1134
1135 fn three_atom_collection() -> AtomCollection {
1137 let coords = vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 3.0, 0.0]];
1138 let res_ids = vec![1, 1, 1];
1139 let res_names = vec!["ALA".to_string(); 3];
1140 let is_hetero = vec![false; 3];
1141 let elements = vec![Element::N, Element::C, Element::C];
1142 let atom_names = vec!["N".to_string(), "CA".to_string(), "C".to_string()];
1143 let chain_ids = vec!["A".to_string(); 3];
1144 AtomCollection::new(
1145 3, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
1146 )
1147 }
1148
1149 #[test]
1150 fn test_aabb_from_masked_atoms() {
1151 let ac = three_atom_collection();
1152 let mask = AtomMask(vec![true; 3]);
1153 let (min, max) = aabb_of_masked(&ac, &mask).unwrap();
1154 let center = (min + max) * 0.5;
1155 let diag = (max - min).length();
1156 assert!((center - Vec3::new(1.0, 1.5, 0.0)).length() < 1e-5);
1157 assert!((diag - 13.0_f32.sqrt()).abs() < 1e-4);
1158 }
1159
1160 #[test]
1161 fn test_aabb_empty_mask_is_none() {
1162 let ac = three_atom_collection();
1163 let mask = AtomMask(vec![false; 3]);
1164 assert!(aabb_of_masked(&ac, &mask).is_none());
1165 }
1166
1167 #[test]
1168 fn test_centroid_from_masked_atoms() {
1169 let ac = three_atom_collection();
1170 let mask = AtomMask(vec![true; 3]);
1171 let c = centroid_of_masked(&ac, &mask).unwrap();
1172 assert!((c - Vec3::new(1.0, 1.0, 0.0)).length() < 1e-5);
1173 }
1174
1175 #[test]
1176 fn test_centroid_single_atom() {
1177 let coords = vec![[5.0, 5.0, 5.0]];
1178 let ac = AtomCollection::new(
1179 1,
1180 coords,
1181 vec![1],
1182 vec!["ALA".to_string()],
1183 vec![false],
1184 vec![Element::C],
1185 vec!["CA".to_string()],
1186 vec!["A".to_string()],
1187 None,
1188 );
1189 let mask = AtomMask(vec![true]);
1190 let c = centroid_of_masked(&ac, &mask).unwrap();
1191 assert_eq!(c, Vec3::new(5.0, 5.0, 5.0));
1192 }
1193
1194 #[test]
1195 fn test_map_repr_cartoon() {
1196 let (opt, degraded) = map_representation(&RepresentationTypeT::Cartoon);
1197 assert!(matches!(opt, RenderOptions::Cartoon));
1198 assert!(!degraded);
1199 }
1200
1201 #[test]
1202 fn test_map_repr_ball_and_stick() {
1203 let (opt, degraded) = map_representation(&RepresentationTypeT::BallAndStick);
1204 assert!(matches!(opt, RenderOptions::BallAndStick));
1205 assert!(!degraded);
1206 }
1207
1208 #[test]
1209 fn test_map_repr_surface_degrades() {
1210 let (opt, degraded) = map_representation(&RepresentationTypeT::Surface);
1211 assert!(matches!(opt, RenderOptions::Solid));
1212 assert!(degraded, "Surface must signal degradation");
1213 }
1214
1215 #[test]
1216 fn test_map_repr_spacefill() {
1217 let (opt, degraded) = map_representation(&RepresentationTypeT::Spacefill);
1218 assert!(matches!(opt, RenderOptions::Solid));
1219 assert!(
1220 !degraded,
1221 "Spacefill maps to Solid with VDW radii — not degraded"
1222 );
1223 }
1224
1225 #[test]
1226 fn test_map_repr_putty() {
1227 let (opt, degraded) = map_representation(&RepresentationTypeT::Putty);
1228 assert!(matches!(opt, RenderOptions::Putty));
1229 assert!(!degraded);
1230 }
1231
1232 #[test]
1233 fn test_map_repr_line() {
1234 let (opt, degraded) = map_representation(&RepresentationTypeT::Line);
1235 assert!(matches!(opt, RenderOptions::Wireframe));
1236 assert!(!degraded);
1237 }
1238
1239 #[test]
1240 fn test_rotation_matrix_wrong_length() {
1241 let params = TransformParams {
1242 rotation: Some(vec![1.0; 8]),
1243 translation: None,
1244 };
1245 assert_eq!(parse_transform(¶ms), Err(()));
1246 }
1247
1248 #[test]
1249 fn test_transform_identity_rotation_and_translation() {
1250 let params = TransformParams {
1251 rotation: Some(vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
1252 translation: Some((1.0, 2.0, 3.0)),
1253 };
1254 let t = parse_transform(¶ms).unwrap();
1255 assert!((t.translation - Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5);
1256 assert!(t.rotation.angle_between(Quat::IDENTITY) < 1e-4);
1257 }
1258
1259 #[test]
1260 fn test_selector_description_does_not_panic() {
1261 let s = describe_selector(&ComponentSelector::Selector(ComponentSelectorT::Water));
1262 assert!(s.contains("Water"));
1263 }
1264
1265 #[test]
1269 fn test_basic_load_spawns_one_mesh() {
1270 use ferritin_test_data::TestFile;
1271 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1272 let mut app = make_app();
1273 send_and_update(
1274 &mut app,
1275 &mvsj_one_component(&path, "all", "ball_and_stick"),
1276 );
1277 assert_eq!(count_meshes(&mut app), 1, "T3-10: expected 1 mesh entity");
1278 }
1279
1280 #[test]
1282 fn test_two_components_two_meshes() {
1283 use ferritin_test_data::TestFile;
1284 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1285 let json = format!(
1286 r#"{{
1287 "root": {{
1288 "kind": "root",
1289 "children": [{{
1290 "kind": "download",
1291 "params": {{"url": "{path}"}},
1292 "children": [{{
1293 "kind": "parse",
1294 "params": {{"format": "mmcif"}},
1295 "children": [{{
1296 "kind": "structure",
1297 "params": {{"type": "model"}},
1298 "children": [
1299 {{
1300 "kind": "component",
1301 "params": {{"selector": "protein"}},
1302 "children": [
1303 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
1304 {{"kind": "focus", "params": {{}}}}
1305 ]
1306 }},
1307 {{
1308 "kind": "component",
1309 "params": {{"selector": "water"}},
1310 "children": [
1311 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}
1312 ]
1313 }}
1314 ]
1315 }}]
1316 }}]
1317 }}]
1318 }},
1319 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1320 }}"#
1321 );
1322 let mut app = make_app();
1323 send_and_update(&mut app, &json);
1324 assert_eq!(
1325 count_meshes(&mut app),
1326 2,
1327 "T3-11: protein + water → 2 meshes"
1328 );
1329 }
1330
1331 #[test]
1333 fn test_label_spawns_entity() {
1334 use ferritin_test_data::TestFile;
1335 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1336 let json = format!(
1337 r#"{{
1338 "root": {{
1339 "kind": "root",
1340 "children": [{{
1341 "kind": "download",
1342 "params": {{"url": "{path}"}},
1343 "children": [{{
1344 "kind": "parse",
1345 "params": {{"format": "mmcif"}},
1346 "children": [{{
1347 "kind": "structure",
1348 "params": {{"type": "model"}},
1349 "children": [{{
1350 "kind": "component",
1351 "params": {{"selector": "all"}},
1352 "children": [
1353 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
1354 {{"kind": "label", "params": {{"text": "Hello World"}}}}
1355 ]
1356 }}]
1357 }}]
1358 }}]
1359 }}]
1360 }},
1361 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1362 }}"#
1363 );
1364 let mut app = make_app();
1365 send_and_update(&mut app, &json);
1366 assert_eq!(
1367 count_labels(&mut app),
1368 1,
1369 "T3-13: expected 1 MvsLabel entity"
1370 );
1371 }
1372
1373 #[test]
1376 fn test_label_billboard_ui_node_created() {
1377 use ferritin_test_data::TestFile;
1378 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1379 let json = format!(
1380 r#"{{
1381 "root": {{
1382 "kind": "root",
1383 "children": [{{
1384 "kind": "download",
1385 "params": {{"url": "{path}"}},
1386 "children": [{{
1387 "kind": "parse",
1388 "params": {{"format": "mmcif"}},
1389 "children": [{{
1390 "kind": "structure",
1391 "params": {{"type": "model"}},
1392 "children": [{{
1393 "kind": "component",
1394 "params": {{"selector": "all"}},
1395 "children": [
1396 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
1397 {{"kind": "label", "params": {{"text": "Hello World"}}}}
1398 ]
1399 }}]
1400 }}]
1401 }}]
1402 }}]
1403 }},
1404 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1405 }}"#
1406 );
1407 let mut app = make_app();
1408 let camera_transform = Transform::from_xyz(0.0, 0.0, 50.0).looking_at(Vec3::ZERO, Vec3::Y);
1409 let viewport_size = UVec2::new(800, 600);
1410 let mut projection = Projection::default();
1411 projection.update(viewport_size.x as f32, viewport_size.y as f32);
1412 let mut camera = Camera {
1413 viewport: Some(bevy::camera::Viewport {
1414 physical_size: viewport_size,
1415 ..default()
1416 }),
1417 ..default()
1418 };
1419 camera.computed.clip_from_view = projection.get_clip_from_view();
1423 camera.computed.target_info = Some(bevy::camera::RenderTargetInfo {
1424 physical_size: viewport_size,
1425 scale_factor: 1.0,
1426 });
1427 app.world_mut().spawn((
1428 Camera3d::default(),
1429 camera,
1430 projection,
1431 camera_transform,
1432 GlobalTransform::from(camera_transform),
1433 ));
1434 send_and_update(&mut app, &json);
1435 app.update();
1439
1440 let mut label_query = app.world_mut().query::<&MvsLabelUiNode>();
1441 assert_eq!(
1442 label_query.iter(app.world()).count(),
1443 1,
1444 "expected the label anchor to gain a MvsLabelUiNode link"
1445 );
1446
1447 let mut text_query = app.world_mut().query::<(&Text, &UiNode)>();
1448 let (text, node) = text_query
1449 .single(app.world())
1450 .expect("expected exactly one billboard Text/UiNode entity");
1451 assert_eq!(text.0, "Hello World");
1452 assert_eq!(node.position_type, PositionType::Absolute);
1453 }
1454
1455 #[test]
1459 fn test_line_representation_material_is_lit() {
1460 use ferritin_test_data::TestFile;
1461 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1462 let mut app = make_app();
1463 send_and_update(&mut app, &mvsj_one_component(&path, "all", "line"));
1464
1465 let mut query = app.world_mut().query::<&MeshMaterial3d<StandardMaterial>>();
1466 let handle = query
1467 .single(app.world())
1468 .expect("expected exactly one mesh material")
1469 .clone();
1470 let materials = app.world().resource::<Assets<StandardMaterial>>();
1471 let material = materials.get(&handle.0).expect("material must exist");
1472 assert!(
1473 !material.unlit,
1474 "line representation material should keep normal PBR shading"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_ball_and_stick_material_is_lit() {
1480 use ferritin_test_data::TestFile;
1481 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1482 let mut app = make_app();
1483 send_and_update(
1484 &mut app,
1485 &mvsj_one_component(&path, "all", "ball_and_stick"),
1486 );
1487
1488 let mut query = app.world_mut().query::<&MeshMaterial3d<StandardMaterial>>();
1489 let handle = query
1490 .single(app.world())
1491 .expect("expected exactly one mesh material")
1492 .clone();
1493 let materials = app.world().resource::<Assets<StandardMaterial>>();
1494 let material = materials.get(&handle.0).expect("material must exist");
1495 assert!(
1496 !material.unlit,
1497 "ball_and_stick representation material should keep normal PBR shading"
1498 );
1499 }
1500
1501 #[test]
1505 fn test_surface_fallback_is_tinted() {
1506 use ferritin_test_data::TestFile;
1507 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1508 let mut app = make_app();
1509 send_and_update(&mut app, &mvsj_one_component(&path, "all", "surface"));
1510
1511 let mut query = app.world_mut().query::<&MeshMaterial3d<StandardMaterial>>();
1512 let handle = query
1513 .single(app.world())
1514 .expect("expected exactly one mesh material")
1515 .clone();
1516 let materials = app.world().resource::<Assets<StandardMaterial>>();
1517 let material = materials.get(&handle.0).expect("material must exist");
1518 assert_ne!(
1519 material.base_color,
1520 Color::WHITE,
1521 "degraded Surface fallback should be visibly tinted, not default white"
1522 );
1523 }
1524
1525 #[test]
1529 fn test_surface_fallback_emits_mvs_error() {
1530 use ferritin_test_data::TestFile;
1531 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1532 let mut app = make_app();
1533 send_and_update(&mut app, &mvsj_one_component(&path, "all", "surface"));
1534
1535 let errors = get_errors(&app);
1536 assert!(
1537 errors.iter().any(|e| matches!(
1538 e,
1539 MvsError::RepresentationDegraded {
1540 requested: RepresentationTypeT::Surface,
1541 ..
1542 }
1543 )),
1544 "expected a RepresentationDegraded error for the Surface fallback, got {errors:?}"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_spacefill_is_not_tinted() {
1550 use ferritin_test_data::TestFile;
1551 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1552 let mut app = make_app();
1553 send_and_update(&mut app, &mvsj_one_component(&path, "all", "spacefill"));
1554
1555 let mut query = app.world_mut().query::<&MeshMaterial3d<StandardMaterial>>();
1556 let handle = query
1557 .single(app.world())
1558 .expect("expected exactly one mesh material")
1559 .clone();
1560 let materials = app.world().resource::<Assets<StandardMaterial>>();
1561 let material = materials.get(&handle.0).expect("material must exist");
1562 assert_eq!(
1563 material.base_color,
1564 Color::WHITE,
1565 "a real (non-degraded) spacefill request should not carry the fallback tint"
1566 );
1567 }
1568
1569 #[test]
1571 fn test_focus_repositions_camera() {
1572 use ferritin_test_data::TestFile;
1573 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1574 let json = format!(
1575 r#"{{
1576 "root": {{
1577 "kind": "root",
1578 "children": [{{
1579 "kind": "download",
1580 "params": {{"url": "{path}"}},
1581 "children": [{{
1582 "kind": "parse",
1583 "params": {{"format": "mmcif"}},
1584 "children": [{{
1585 "kind": "structure",
1586 "params": {{"type": "model"}},
1587 "children": [{{
1588 "kind": "component",
1589 "params": {{"selector": "all"}},
1590 "children": [
1591 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
1592 {{"kind": "focus", "params": {{}}}}
1593 ]
1594 }}]
1595 }}]
1596 }}]
1597 }}]
1598 }},
1599 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1600 }}"#
1601 );
1602 let mut app = make_app();
1603 let default_orbit = OrbitCamera::default();
1604 send_and_update(&mut app, &json);
1605 let orbit = app.world().resource::<OrbitCamera>().clone();
1606 assert_ne!(
1608 orbit.radius, default_orbit.radius,
1609 "T3-14: focus should change orbit radius"
1610 );
1611 }
1612
1613 #[test]
1621 fn test_focus_small_target_clamped_to_scene_bounds() {
1622 use ferritin_test_data::TestFile;
1623
1624 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
1625
1626 let json_small_only = format!(
1628 r#"{{
1629 "root": {{
1630 "kind": "root",
1631 "children": [{{
1632 "kind": "download",
1633 "params": {{"url": "{path}"}},
1634 "children": [{{
1635 "kind": "parse",
1636 "params": {{"format": "mmcif"}},
1637 "children": [{{
1638 "kind": "structure",
1639 "params": {{"type": "model"}},
1640 "children": [{{
1641 "kind": "component",
1642 "params": {{"selector": {{"residue_index": 0}}}},
1643 "children": [
1644 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
1645 {{"kind": "focus", "params": {{}}}}
1646 ]
1647 }}]
1648 }}]
1649 }}]
1650 }}]
1651 }},
1652 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1653 }}"#
1654 );
1655 let mut app_small = make_app();
1656 send_and_update(&mut app_small, &json_small_only);
1657 let radius_small_only = app_small.world().resource::<OrbitCamera>().radius;
1658
1659 let json_with_scene = format!(
1663 r#"{{
1664 "root": {{
1665 "kind": "root",
1666 "children": [{{
1667 "kind": "download",
1668 "params": {{"url": "{path}"}},
1669 "children": [{{
1670 "kind": "parse",
1671 "params": {{"format": "mmcif"}},
1672 "children": [{{
1673 "kind": "structure",
1674 "params": {{"type": "model"}},
1675 "children": [
1676 {{
1677 "kind": "component",
1678 "params": {{"selector": "all"}},
1679 "children": [
1680 {{"kind": "representation", "params": {{"type": "cartoon"}}}}
1681 ]
1682 }},
1683 {{
1684 "kind": "component",
1685 "params": {{"selector": {{"residue_index": 0}}}},
1686 "children": [
1687 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
1688 {{"kind": "focus", "params": {{}}}}
1689 ]
1690 }}
1691 ]
1692 }}]
1693 }}]
1694 }}]
1695 }},
1696 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1697 }}"#
1698 );
1699 let mut app_scene = make_app();
1700 send_and_update(&mut app_scene, &json_with_scene);
1701 let radius_with_scene = app_scene.world().resource::<OrbitCamera>().radius;
1702
1703 assert!(
1704 radius_with_scene > radius_small_only * 3.0,
1705 "expected the scene-bounds clamp to pull the camera back well beyond \
1706 the tiny target's own fit (small-only={radius_small_only}, \
1707 with-scene={radius_with_scene})"
1708 );
1709 }
1710
1711 #[test]
1718 fn test_fit_radius_tighter_on_widescreen_for_wide_aabb() {
1719 let orbit = OrbitCamera::default();
1720 let min = Vec3::new(-100.0, -5.0, -5.0);
1722 let max = Vec3::new(100.0, 5.0, 5.0);
1723
1724 let radius_square = fit_radius_for_view(min, max, &orbit, 1.0);
1725 let radius_widescreen = fit_radius_for_view(min, max, &orbit, 16.0 / 9.0);
1726
1727 assert!(
1728 radius_widescreen < radius_square,
1729 "widescreen fit ({radius_widescreen}) should pull in tighter than \
1730 square fit ({radius_square}) for a horizontally-dominant AABB"
1731 );
1732 }
1733
1734 #[test]
1738 fn test_fit_radius_unaffected_by_aspect_for_tall_aabb() {
1739 let orbit = OrbitCamera::default();
1740 let min = Vec3::new(-5.0, -100.0, -5.0);
1741 let max = Vec3::new(5.0, 100.0, 5.0);
1742
1743 let radius_square = fit_radius_for_view(min, max, &orbit, 1.0);
1744 let radius_widescreen = fit_radius_for_view(min, max, &orbit, 16.0 / 9.0);
1745
1746 assert!(
1747 (radius_widescreen - radius_square).abs() < 1e-3,
1748 "vertical-extent fit should be aspect-independent: square={radius_square}, \
1749 widescreen={radius_widescreen}"
1750 );
1751 }
1752
1753 #[test]
1759 fn test_focus_fills_most_of_viewport() {
1760 use ferritin_test_data::TestFile;
1761 let (path, _tmp) = TestFile::mvs_4hhb().create_temp().unwrap();
1762 let json = format!(
1763 r#"{{
1764 "root": {{
1765 "kind": "root",
1766 "children": [{{
1767 "kind": "download",
1768 "params": {{"url": "{path}"}},
1769 "children": [{{
1770 "kind": "parse",
1771 "params": {{"format": "mmcif"}},
1772 "children": [{{
1773 "kind": "structure",
1774 "params": {{"type": "model"}},
1775 "children": [{{
1776 "kind": "component",
1777 "params": {{"selector": "all"}},
1778 "children": [
1779 {{"kind": "representation", "params": {{"type": "cartoon"}}}},
1780 {{"kind": "focus", "params": {{}}}}
1781 ]
1782 }}]
1783 }}]
1784 }}]
1785 }}]
1786 }},
1787 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
1788 }}"#
1789 );
1790 let mut app = make_app();
1791 send_and_update(&mut app, &json);
1792 let orbit = app.world().resource::<OrbitCamera>().clone();
1793
1794 let camera_transform = Transform::from_translation({
1795 let x = orbit.radius * orbit.yaw.sin() * orbit.pitch.cos();
1796 let y = orbit.radius * orbit.pitch.sin();
1797 let z = orbit.radius * orbit.yaw.cos() * orbit.pitch.cos();
1798 orbit.focus + Vec3::new(x, y, z)
1799 })
1800 .looking_at(orbit.focus, Vec3::Y);
1801 let viewport_size = UVec2::new(2560, 1440);
1802 let mut projection = Projection::default();
1803 projection.update(viewport_size.x as f32, viewport_size.y as f32);
1804 let mut camera = Camera {
1805 viewport: Some(bevy::camera::Viewport {
1806 physical_size: viewport_size,
1807 ..default()
1808 }),
1809 ..default()
1810 };
1811 camera.computed.clip_from_view = projection.get_clip_from_view();
1812 camera.computed.target_info = Some(bevy::camera::RenderTargetInfo {
1813 physical_size: viewport_size,
1814 scale_factor: 1.0,
1815 });
1816 let gt = GlobalTransform::from(camera_transform);
1817
1818 let mut mesh_query = app.world_mut().query::<&Mesh3d>();
1819 let mesh_handle = mesh_query
1820 .single(app.world())
1821 .expect("expected 1 mesh")
1822 .clone();
1823 let meshes = app.world().resource::<Assets<Mesh>>();
1824 let mesh = meshes.get(&mesh_handle.0).expect("mesh must exist");
1825 let positions = match mesh
1826 .attribute(Mesh::ATTRIBUTE_POSITION)
1827 .expect("mesh must have positions")
1828 {
1829 bevy::render::mesh::VertexAttributeValues::Float32x3(v) => v,
1830 _ => panic!("unexpected position format"),
1831 };
1832
1833 let mut min_px = Vec2::splat(f32::MAX);
1834 let mut max_px = Vec2::splat(f32::MIN);
1835 for p in positions {
1836 if let Ok(px) = camera.world_to_viewport(>, Vec3::from_array(*p)) {
1837 min_px = min_px.min(px);
1838 max_px = max_px.max(px);
1839 }
1840 }
1841
1842 let fill_height = (max_px.y - min_px.y) / viewport_size.y as f32;
1843 let fill_width = (max_px.x - min_px.x) / viewport_size.x as f32;
1844 assert!(
1845 fill_height > 0.5 || fill_width > 0.5,
1846 "expected focus() to fill most of the viewport, got fill_height={fill_height:.2} fill_width={fill_width:.2}"
1847 );
1848 }
1849
1850 #[test]
1852 fn test_canvas_sets_clear_color() {
1853 let json = r#"{
1854 "root": {
1855 "kind": "root",
1856 "children": [{
1857 "kind": "canvas",
1858 "params": {"background_color": "red"}
1859 }]
1860 },
1861 "metadata": {"version": "1.0", "timestamp": "2024-01-01T00:00:00"}
1862 }"#;
1863 let mut app = make_app();
1864 send_and_update(&mut app, json);
1865 let cc = app.world().resource::<ClearColor>();
1866 let r = cc.0.to_linear();
1868 assert!(
1869 r.red > 0.9 && r.green < 0.1,
1870 "T3-15: canvas must set red background"
1871 );
1872 }
1873
1874 #[test]
1877 fn test_camera3d_gets_molecular_viewer_defaults() {
1878 let mut app = make_app();
1879 let camera = app
1880 .world_mut()
1881 .spawn((Camera3d::default(), Transform::default()))
1882 .id();
1883 app.update();
1884
1885 let tonemapping = app.world().get::<Tonemapping>(camera);
1886 assert_eq!(
1887 tonemapping,
1888 Some(&Tonemapping::None),
1889 "Camera3d should have tonemapping disabled by MvsPlugin"
1890 );
1891 let ssao = app.world().get::<ScreenSpaceAmbientOcclusion>(camera);
1892 assert_eq!(
1893 ssao.map(|s| s.quality_level),
1894 Some(ScreenSpaceAmbientOcclusionQualityLevel::High),
1895 "Camera3d should have SSAO enabled by MvsPlugin"
1896 );
1897 assert_eq!(
1898 app.world().get::<Msaa>(camera),
1899 Some(&Msaa::Off),
1900 "SSAO requires Msaa::Off; with MSAA on bevy skips SSAO and logs every frame"
1901 );
1902 assert!(
1903 app.world().get::<Fxaa>(camera).is_some(),
1904 "FXAA replaces MSAA edge anti-aliasing"
1905 );
1906 }
1907
1908 #[test]
1910 fn test_camera_node_sets_orbit() {
1911 let json = r#"{
1912 "root": {
1913 "kind": "root",
1914 "children": [{
1915 "kind": "camera",
1916 "params": {
1917 "target": [10.0, 20.0, 30.0],
1918 "position": [10.0, 20.0, 80.0],
1919 "up": [0.0, 1.0, 0.0]
1920 }
1921 }]
1922 },
1923 "metadata": {"version": "1.0", "timestamp": "2024-01-01T00:00:00"}
1924 }"#;
1925 let mut app = make_app();
1926 send_and_update(&mut app, json);
1927 let orbit = app.world().resource::<OrbitCamera>().clone();
1928 assert!(
1929 (orbit.focus - Vec3::new(10.0, 20.0, 30.0)).length() < 1e-3,
1930 "T3-16: camera target must set orbit focus"
1931 );
1932 assert!(
1933 (orbit.radius - 50.0).abs() < 1e-3,
1934 "T3-16: distance from target to position = 50"
1935 );
1936 }
1937
1938 #[test]
1940 fn test_http_url_emits_unsupported_error() {
1941 let json = r#"{
1942 "root": {
1943 "kind": "root",
1944 "children": [{
1945 "kind": "download",
1946 "params": {"url": "https://files.wwpdb.org/download/1cbs.cif"},
1947 "children": [{"kind": "parse", "params": {"format": "mmcif"}, "children": []}]
1948 }]
1949 },
1950 "metadata": {"version": "1.0", "timestamp": "2024-01-01T00:00:00"}
1951 }"#;
1952 let mut app = make_app();
1953 send_and_update(&mut app, json);
1954 let errors = get_errors(&app);
1955 assert!(
1956 errors.iter().any(|e| matches!(
1957 e,
1958 MvsError::UnsupportedNode {
1959 kind: KindT::Download,
1960 ..
1961 }
1962 )),
1963 "T3-17: HTTP download must emit UnsupportedNode"
1964 );
1965 assert_eq!(count_meshes(&mut app), 0, "T3-17: no meshes for http");
1966 }
1967
1968 #[test]
1970 fn test_bad_path_emits_file_not_found() {
1971 let json = r#"{
1972 "root": {
1973 "kind": "root",
1974 "children": [{
1975 "kind": "download",
1976 "params": {"url": "/nonexistent/path/nope.cif"},
1977 "children": [{"kind": "parse", "params": {"format": "mmcif"}, "children": []}]
1978 }]
1979 },
1980 "metadata": {"version": "1.0", "timestamp": "2024-01-01T00:00:00"}
1981 }"#;
1982 let mut app = make_app();
1983 send_and_update(&mut app, json);
1984 let errors = get_errors(&app);
1985 assert!(
1986 errors
1987 .iter()
1988 .any(|e| matches!(e, MvsError::FileNotFound(_))),
1989 "T3-18: bad path must emit FileNotFound"
1990 );
1991 assert_eq!(
1992 count_meshes(&mut app),
1993 0,
1994 "T3-18: no meshes on file not found"
1995 );
1996 }
1997
1998 #[test]
2000 fn test_empty_selector_emits_error_and_sibling_continues() {
2001 use ferritin_test_data::TestFile;
2002 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2003 let json = format!(
2005 r#"{{
2006 "root": {{
2007 "kind": "root",
2008 "children": [{{
2009 "kind": "download",
2010 "params": {{"url": "{path}"}},
2011 "children": [{{
2012 "kind": "parse",
2013 "params": {{"format": "mmcif"}},
2014 "children": [{{
2015 "kind": "structure",
2016 "params": {{"type": "model"}},
2017 "children": [
2018 {{
2019 "kind": "component",
2020 "params": {{"selector": "nucleic"}},
2021 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2022 }},
2023 {{
2024 "kind": "component",
2025 "params": {{"selector": "all"}},
2026 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2027 }}
2028 ]
2029 }}]
2030 }}]
2031 }}]
2032 }},
2033 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2034 }}"#
2035 );
2036 let mut app = make_app();
2037 send_and_update(&mut app, &json);
2038 let errors = get_errors(&app);
2039 assert!(
2040 errors
2041 .iter()
2042 .any(|e| matches!(e, MvsError::NoAtomsSelected { .. })),
2043 "T3-19: empty selector must emit NoAtomsSelected"
2044 );
2045 assert_eq!(
2047 count_meshes(&mut app),
2048 1,
2049 "T3-19: sibling component must still render"
2050 );
2051 }
2052
2053 #[test]
2055 fn test_assembly_emits_warning_and_renders() {
2056 use ferritin_test_data::TestFile;
2057 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2058 let json = format!(
2059 r#"{{
2060 "root": {{
2061 "kind": "root",
2062 "children": [{{
2063 "kind": "download",
2064 "params": {{"url": "{path}"}},
2065 "children": [{{
2066 "kind": "parse",
2067 "params": {{"format": "mmcif"}},
2068 "children": [{{
2069 "kind": "structure",
2070 "params": {{"type": "assembly"}},
2071 "children": [{{
2072 "kind": "component",
2073 "params": {{"selector": "all"}},
2074 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2075 }}]
2076 }}]
2077 }}]
2078 }}]
2079 }},
2080 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2081 }}"#
2082 );
2083 let mut app = make_app();
2084 send_and_update(&mut app, &json);
2085 let errors = get_errors(&app);
2086 assert!(
2087 errors.iter().any(|e| matches!(
2088 e,
2089 MvsError::UnsupportedNode {
2090 kind: KindT::Structure,
2091 ..
2092 }
2093 )),
2094 "T3-20: assembly must emit UnsupportedNode"
2095 );
2096 assert_eq!(
2097 count_meshes(&mut app),
2098 1,
2099 "T3-20: assembly still renders asymmetric unit"
2100 );
2101 }
2102
2103 #[test]
2105 fn test_symmetry_emits_warning_and_renders() {
2106 use ferritin_test_data::TestFile;
2107 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2108 let json = format!(
2109 r#"{{
2110 "root": {{
2111 "kind": "root",
2112 "children": [{{
2113 "kind": "download",
2114 "params": {{"url": "{path}"}},
2115 "children": [{{
2116 "kind": "parse",
2117 "params": {{"format": "mmcif"}},
2118 "children": [{{
2119 "kind": "structure",
2120 "params": {{"type": "symmetry"}},
2121 "children": [{{
2122 "kind": "component",
2123 "params": {{"selector": "all"}},
2124 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2125 }}]
2126 }}]
2127 }}]
2128 }}]
2129 }},
2130 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2131 }}"#
2132 );
2133 let mut app = make_app();
2134 send_and_update(&mut app, &json);
2135 let errors = get_errors(&app);
2136 assert!(
2137 errors.iter().any(|e| matches!(
2138 e,
2139 MvsError::UnsupportedNode {
2140 kind: KindT::Structure,
2141 ..
2142 }
2143 )),
2144 "T3-21: symmetry must emit UnsupportedNode"
2145 );
2146 assert_eq!(count_meshes(&mut app), 1, "T3-21: symmetry still renders");
2147 }
2148
2149 #[test]
2151 fn test_component_from_uri_emits_unsupported() {
2152 use ferritin_test_data::TestFile;
2153 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2154 let json = format!(
2155 r#"{{
2156 "root": {{
2157 "kind": "root",
2158 "children": [{{
2159 "kind": "download",
2160 "params": {{"url": "{path}"}},
2161 "children": [{{
2162 "kind": "parse",
2163 "params": {{"format": "mmcif"}},
2164 "children": [{{
2165 "kind": "structure",
2166 "params": {{"type": "model"}},
2167 "children": [
2168 {{
2169 "kind": "component_from_uri",
2170 "params": {{"uri": "https://example.com/sel.json", "format": "json", "schema": "all_atomic"}},
2171 "children": []
2172 }},
2173 {{
2174 "kind": "component",
2175 "params": {{"selector": "all"}},
2176 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2177 }}
2178 ]
2179 }}]
2180 }}]
2181 }}]
2182 }},
2183 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2184 }}"#
2185 );
2186 let mut app = make_app();
2187 send_and_update(&mut app, &json);
2188 let errors = get_errors(&app);
2189 assert!(
2190 errors.iter().any(|e| matches!(
2191 e,
2192 MvsError::UnsupportedNode {
2193 kind: KindT::ComponentFromUri,
2194 ..
2195 }
2196 )),
2197 "T3-22: component_from_uri must emit UnsupportedNode"
2198 );
2199 assert_eq!(
2200 count_meshes(&mut app),
2201 1,
2202 "T3-22: sibling component still renders"
2203 );
2204 }
2205
2206 #[test]
2208 fn test_scene_clear_on_reload() {
2209 use ferritin_test_data::TestFile;
2210 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2211 let json = mvsj_one_component(&path, "all", "ball_and_stick");
2212 let mut app = make_app();
2213
2214 send_and_update(&mut app, &json);
2216 assert_eq!(count_meshes(&mut app), 1, "T3-23: first load spawns 1 mesh");
2217
2218 let json2 = mvsj_one_component(&path, "water", "ball_and_stick");
2220 send_and_update(&mut app, &json2);
2221 assert_eq!(
2224 count_meshes(&mut app),
2225 1,
2226 "T3-23: second load replaces first, still 1 mesh"
2227 );
2228 assert_eq!(
2229 count_retired_meshes(&mut app),
2230 1,
2231 "T3-23: old scene is retired for one frame"
2232 );
2233
2234 app.update();
2235 assert_eq!(
2236 count_retired_meshes(&mut app),
2237 0,
2238 "T3-23: retired scene is cleaned up on the following update"
2239 );
2240 }
2241
2242 #[test]
2244 fn test_generic_sphere_spawns_entity() {
2245 let json = r#"{
2246 "root": {
2247 "kind": "root",
2248 "children": [{
2249 "kind": "generic_visuals",
2250 "children": [{
2251 "kind": "sphere",
2252 "params": {
2253 "position": [0.0, 0.0, 0.0],
2254 "radius": 1.0,
2255 "color": "red",
2256 "label": "test"
2257 }
2258 }]
2259 }]
2260 },
2261 "metadata": {"version": "1.0", "timestamp": "2024-01-01T00:00:00"}
2262 }"#;
2263 let mut app = make_app();
2264 send_and_update(&mut app, json);
2265 assert_eq!(
2266 count_meshes(&mut app),
2267 1,
2268 "T3-24: sphere must spawn 1 MvsEntity mesh"
2269 );
2270 }
2271
2272 #[test]
2274 fn test_generic_line_spawns_entity() {
2275 let json = r#"{
2276 "root": {
2277 "kind": "root",
2278 "children": [{
2279 "kind": "generic_visuals",
2280 "children": [{
2281 "kind": "line",
2282 "params": {
2283 "position1": [0.0, 0.0, 0.0],
2284 "position2": [1.0, 1.0, 1.0],
2285 "radius": 0.1,
2286 "color": "blue",
2287 "label": "line"
2288 }
2289 }]
2290 }]
2291 },
2292 "metadata": {"version": "1.0", "timestamp": "2024-01-01T00:00:00"}
2293 }"#;
2294 let mut app = make_app();
2295 send_and_update(&mut app, json);
2296 assert_eq!(
2297 count_meshes(&mut app),
2298 1,
2299 "T3-25: line must spawn 1 MvsEntity mesh"
2300 );
2301 }
2302
2303 #[test]
2305 fn test_transform_applied_to_component() {
2306 use ferritin_test_data::TestFile;
2307 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2308 let json = format!(
2309 r#"{{
2310 "root": {{
2311 "kind": "root",
2312 "children": [{{
2313 "kind": "download",
2314 "params": {{"url": "{path}"}},
2315 "children": [{{
2316 "kind": "parse",
2317 "params": {{"format": "mmcif"}},
2318 "children": [{{
2319 "kind": "structure",
2320 "params": {{"type": "model"}},
2321 "children": [
2322 {{
2323 "kind": "transform",
2324 "params": {{
2325 "rotation": [1,0,0, 0,1,0, 0,0,1],
2326 "translation": [10.0, 0.0, 0.0]
2327 }}
2328 }},
2329 {{
2330 "kind": "component",
2331 "params": {{"selector": "all"}},
2332 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2333 }}
2334 ]
2335 }}]
2336 }}]
2337 }}]
2338 }},
2339 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2340 }}"#
2341 );
2342 let mut app = make_app();
2343 send_and_update(&mut app, &json);
2344 assert_eq!(
2346 count_meshes(&mut app),
2347 1,
2348 "T3-26: transform + component → 1 mesh"
2349 );
2350 let errors = get_errors(&app);
2352 assert!(
2353 !errors
2354 .iter()
2355 .any(|e| matches!(e, MvsError::InvalidTransform)),
2356 "T3-26: valid transform must not emit InvalidTransform"
2357 );
2358 }
2359
2360 #[test]
2362 fn test_no_transform_uses_identity() {
2363 use ferritin_test_data::TestFile;
2364 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2365 let mut app = make_app();
2366 send_and_update(
2367 &mut app,
2368 &mvsj_one_component(&path, "all", "ball_and_stick"),
2369 );
2370 let errors = get_errors(&app);
2371 assert!(
2372 !errors
2373 .iter()
2374 .any(|e| matches!(e, MvsError::InvalidTransform)),
2375 "T3-27: no transform node must not emit InvalidTransform"
2376 );
2377 assert_eq!(
2378 count_meshes(&mut app),
2379 1,
2380 "T3-27: no transform → mesh spawned"
2381 );
2382 }
2383
2384 #[test]
2386 fn test_two_downloads_two_meshes() {
2387 use ferritin_test_data::TestFile;
2388 let (path1, _tmp1) = TestFile::protein_01().create_temp().unwrap();
2390 let (path2, _tmp2) = TestFile::protein_03().create_temp().unwrap();
2391 let json = format!(
2392 r#"{{
2393 "root": {{
2394 "kind": "root",
2395 "children": [
2396 {{
2397 "kind": "download",
2398 "params": {{"url": "{path1}"}},
2399 "children": [{{
2400 "kind": "parse",
2401 "params": {{"format": "mmcif"}},
2402 "children": [{{
2403 "kind": "structure",
2404 "params": {{"type": "model"}},
2405 "children": [{{
2406 "kind": "component",
2407 "params": {{"selector": "all"}},
2408 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2409 }}]
2410 }}]
2411 }}]
2412 }},
2413 {{
2414 "kind": "download",
2415 "params": {{"url": "{path2}"}},
2416 "children": [{{
2417 "kind": "parse",
2418 "params": {{"format": "mmcif"}},
2419 "children": [{{
2420 "kind": "structure",
2421 "params": {{"type": "model"}},
2422 "children": [{{
2423 "kind": "component",
2424 "params": {{"selector": "all"}},
2425 "children": [{{"kind": "representation", "params": {{"type": "ball_and_stick"}}}}]
2426 }}]
2427 }}]
2428 }}]
2429 }}
2430 ]
2431 }},
2432 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2433 }}"#
2434 );
2435 let mut app = make_app();
2436 send_and_update(&mut app, &json);
2437 assert_eq!(count_meshes(&mut app), 2, "T3-28: two downloads → 2 meshes");
2438 }
2439
2440 #[test]
2444 fn test_two_focused_structures_frame_union() {
2445 use ferritin_test_data::TestFile;
2446 let (path1, _tmp1) = TestFile::protein_01().create_temp().unwrap();
2447 let (path2, _tmp2) = TestFile::protein_01().create_temp().unwrap();
2448 let json = format!(
2451 r#"{{
2452 "root": {{
2453 "kind": "root",
2454 "children": [
2455 {{
2456 "kind": "download",
2457 "params": {{"url": "{path1}"}},
2458 "children": [{{
2459 "kind": "parse",
2460 "params": {{"format": "mmcif"}},
2461 "children": [{{
2462 "kind": "structure",
2463 "params": {{"type": "model"}},
2464 "children": [{{
2465 "kind": "component",
2466 "params": {{"selector": "all"}},
2467 "children": [
2468 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
2469 {{"kind": "focus", "params": {{}}}}
2470 ]
2471 }}]
2472 }}]
2473 }}]
2474 }},
2475 {{
2476 "kind": "download",
2477 "params": {{"url": "{path2}"}},
2478 "children": [{{
2479 "kind": "parse",
2480 "params": {{"format": "mmcif"}},
2481 "children": [{{
2482 "kind": "structure",
2483 "params": {{"type": "model"}},
2484 "children": [
2485 {{
2486 "kind": "transform",
2487 "params": {{
2488 "rotation": [1,0,0, 0,1,0, 0,0,1],
2489 "translation": [300.0, 0.0, 0.0]
2490 }}
2491 }},
2492 {{
2493 "kind": "component",
2494 "params": {{"selector": "all"}},
2495 "children": [
2496 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
2497 {{"kind": "focus", "params": {{}}}}
2498 ]
2499 }}
2500 ]
2501 }}]
2502 }}]
2503 }}
2504 ]
2505 }},
2506 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2507 }}"#
2508 );
2509 let mut app = make_app();
2510 send_and_update(&mut app, &json);
2511 let orbit = app.world().resource::<OrbitCamera>().clone();
2512 assert!(
2513 orbit.focus.x > 50.0 && orbit.focus.x < 250.0,
2514 "expected focus to land near the midpoint of both structures (~150), got {}",
2515 orbit.focus.x
2516 );
2517 }
2518
2519 #[test]
2521 fn test_mvs_state_resource_set() {
2522 use ferritin_test_data::TestFile;
2523 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2524 let mut app = make_app();
2525 send_and_update(
2526 &mut app,
2527 &mvsj_one_component(&path, "all", "ball_and_stick"),
2528 );
2529 let state_res = app.world().resource::<MvsStateResource>();
2530 assert!(
2531 state_res.0.is_some(),
2532 "T3-29: MvsStateResource must be Some after successful load"
2533 );
2534 }
2535
2536 #[test]
2538 fn test_mvs_state_resource_replaced() {
2539 use ferritin_test_data::TestFile;
2540 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2541 let mut app = make_app();
2542 send_and_update(
2543 &mut app,
2544 &mvsj_one_component(&path, "all", "ball_and_stick"),
2545 );
2546 send_and_update(
2547 &mut app,
2548 &mvsj_one_component(&path, "water", "ball_and_stick"),
2549 );
2550 let state_res = app.world().resource::<MvsStateResource>();
2551 assert!(
2552 state_res.0.is_some(),
2553 "T3-30: MvsStateResource must remain Some after second load"
2554 );
2555 }
2556
2557 #[test]
2559 fn test_camera_then_focus_focus_wins() {
2560 use ferritin_test_data::TestFile;
2561 let (path, _tmp) = TestFile::protein_01().create_temp().unwrap();
2562 let json = format!(
2564 r#"{{
2565 "root": {{
2566 "kind": "root",
2567 "children": [
2568 {{
2569 "kind": "camera",
2570 "params": {{
2571 "target": [0.0, 0.0, 0.0],
2572 "position": [0.0, 0.0, 1000.0],
2573 "up": [0.0, 1.0, 0.0]
2574 }}
2575 }},
2576 {{
2577 "kind": "download",
2578 "params": {{"url": "{path}"}},
2579 "children": [{{
2580 "kind": "parse",
2581 "params": {{"format": "mmcif"}},
2582 "children": [{{
2583 "kind": "structure",
2584 "params": {{"type": "model"}},
2585 "children": [{{
2586 "kind": "component",
2587 "params": {{"selector": "all"}},
2588 "children": [
2589 {{"kind": "representation", "params": {{"type": "ball_and_stick"}}}},
2590 {{"kind": "focus", "params": {{}}}}
2591 ]
2592 }}]
2593 }}]
2594 }}]
2595 }}
2596 ]
2597 }},
2598 "metadata": {{"version": "1.0", "timestamp": "2024-01-01T00:00:00"}}
2599 }}"#
2600 );
2601 let mut app = make_app();
2602 send_and_update(&mut app, &json);
2603 let orbit = app.world().resource::<OrbitCamera>().clone();
2604 assert!(
2606 orbit.radius < 500.0,
2607 "T3-31: focus must override camera; radius={} should be << 1000",
2608 orbit.radius
2609 );
2610 }
2611}