Skip to main content

ferritin_bevy/
mvs_executor.rs

1//! MVS Execution Engine.
2//!
3//! Walks a parsed MolViewSpec [`State`] tree and spawns Bevy entities that render
4//! it. The engine is *fault tolerant*: an error in one branch of the tree emits an
5//! [`MvsError`] message and execution continues with sibling nodes, so a single bad
6//! download or empty selector never aborts the whole scene.
7//!
8//! # Entry point
9//! Send a [`LoadMvsEvent`] message; the `execute_mvs_on_load` system (registered by
10//! [`MvsPlugin`]) parses the state, clears any previously spawned [`MvsEntity`]
11//! entities, and rebuilds the scene.
12//!
13//! # MVP limitations
14//! - HTTP/HTTPS downloads are not supported (emit [`MvsError::UnsupportedNode`]);
15//!   use local file paths only.
16//! - Assembly / symmetry structures render the plain asymmetric unit and emit an
17//!   [`MvsError::UnsupportedNode`] warning.
18//! - `component_from_uri` / `*_from_uri` / `*_from_source` nodes are skipped with an
19//!   [`MvsError::UnsupportedNode`].
20//! - Surface representation degrades to VdW spheres (Solid).
21//! - When multiple `focus` nodes exist, the last one processed (document order) wins.
22
23use 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// ---------------------------------------------------------------------------
44// Public ECS types
45// ---------------------------------------------------------------------------
46
47/// Marker attached to every entity spawned by the executor. On each reload old
48/// `MvsEntity` entities are hidden, marked retired, then despawned shortly after
49/// the replacement scene is built.
50#[derive(Component)]
51pub struct MvsEntity;
52
53/// Old scene entities are hidden first and despawned a frame later. This keeps
54/// mesh/material handles alive long enough for Bevy's render allocator to observe
55/// the scene transition without a same-frame free/reallocate churn.
56#[derive(Component)]
57struct MvsRetiredEntity {
58    remaining_frames: u8,
59}
60
61/// A MVS `label` node, spawned at the centroid of its component's atoms.
62/// The `update_mvs_label_billboards` system projects this world-space anchor to screen
63/// space each frame and keeps a companion UI [`Text`] entity positioned there.
64#[derive(Component)]
65pub struct MvsLabel(pub String);
66
67/// Links a [`MvsLabel`] anchor entity to its billboard UI [`Text`] entity.
68#[derive(Component)]
69struct MvsLabelUiNode(Entity);
70
71/// Holds the most recently loaded [`State`] (for UI tree inspection). Replaced on
72/// each successful load.
73#[derive(Resource, Default)]
74pub struct MvsStateResource(pub Option<State>);
75
76/// Orbit-camera target state written by `camera` and `focus` nodes. A viewer reads
77/// this resource to position its camera; `focus` always wins over `camera` because
78/// it is applied after the root-level camera node.
79#[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/// Request to load a MolViewSpec state.
102#[derive(Message, Clone, Debug)]
103pub enum LoadMvsEvent {
104    /// Path to a `.mvsj` document on the local filesystem.
105    FromFile(PathBuf),
106    /// A `.mvsj` document already in memory.
107    FromString(String),
108}
109
110/// Non-fatal problems encountered while executing a state. Each is emitted as a
111/// message; a status bar can subscribe to surface them to the user.
112#[derive(Message, Clone, Debug, PartialEq)]
113pub enum MvsError {
114    /// A structure file referenced by a `download` node was not found / failed to load.
115    FileNotFound(PathBuf),
116    /// The `.mvsj` document itself failed to parse.
117    ParseError(String),
118    /// A component's selector matched no atoms; the component was skipped.
119    NoAtomsSelected { selector_description: String },
120    /// A `transform` node carried a malformed rotation matrix.
121    InvalidTransform,
122    /// A node kind / feature is not supported in this MVP.
123    UnsupportedNode { kind: KindT, reason: String },
124    /// A representation type was requested but rendered as a different, degraded
125    /// fallback (e.g. Surface -> VdW spheres). Previously only a `warn!()` log
126    /// call, invisible to anyone using the interactive viewer (ferritin-ala.9).
127    RepresentationDegraded {
128        requested: RepresentationTypeT,
129        rendered_as: &'static str,
130    },
131}
132
133// ---------------------------------------------------------------------------
134// Plugin
135// ---------------------------------------------------------------------------
136
137/// Registers the MVS executor: messages, resources, and the load system.
138///
139/// Adds the minimal asset infrastructure (`AssetPlugin`, `Mesh` + `StandardMaterial`
140/// assets, `ClearColor`) when it is not already present, so the plugin works under
141/// both `MinimalPlugins` (headless tests) and `DefaultPlugins`.
142pub 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
175/// Applies MVS viewer camera defaults. Tonemapping is disabled so explicit
176/// molecular colors remain legible (ferritin-ala.8), and SSAO is enabled to add
177/// contact shadows/depth cues for dense all-atom scenes (ferritin-c1k.4).
178///
179/// SSAO only runs with `Msaa::Off` — with bevy's default `Msaa::Sample4` it is
180/// skipped and bevy logs an error every frame — so MSAA is disabled and FXAA
181/// supplies edge anti-aliasing instead.
182fn 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            // Dithering only acts during tonemapping; bevy warns when it is left on.
190            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
215/// Projects each [`MvsLabel`] anchor's world position through the active 3D
216/// camera and keeps a companion screen-space [`Text`] node positioned there,
217/// creating it on first sight. Labels that project behind the camera or
218/// outside the viewport are hidden rather than despawned, since the anchor
219/// entity (and thus this link) is recreated on every scene reload anyway.
220fn 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
273// ---------------------------------------------------------------------------
274// Load system
275// ---------------------------------------------------------------------------
276
277/// Bundles the mutable world handles the recursive executor needs, plus the error
278/// sink. Errors are buffered here and flushed to the [`MvsError`] message channel
279/// once traversal completes (keeps `MessageWriter` out of the recursion).
280struct 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    /// Union of every `focus()` node's AABB seen so far this load, so a scene with
288    /// multiple focused components (e.g. a superposition) frames all of them
289    /// instead of just the last one processed — see ferritin-ala.5.
290    focus_union: Option<(Vec3, Vec3)>,
291    /// Union of every *rendered* component's world-space AABB seen so far this
292    /// load (regardless of whether it was focused). Used to keep a small
293    /// focus() target (e.g. two ions inside a large protein) from pulling the
294    /// camera in so close that the surrounding, still-rendered geometry
295    /// near-clips — see ferritin-t0h.5.
296    scene_bounds: Option<(Vec3, Vec3)>,
297    /// Live viewport width/height, read from the render camera at load time.
298    /// `fit_radius_for_view` needs this to compute the true horizontal FOV —
299    /// using the (narrower) vertical FOV for both axes on a widescreen viewport
300    /// over-estimated the required distance and left whole-structure framing
301    /// filling under half the frame — see ferritin-t0h.10.
302    aspect_ratio: f32,
303}
304
305/// Reacts to [`LoadMvsEvent`]: parse, clear the previous scene, execute the new tree.
306#[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    // Default to 16:9 (every example in this repo opens at that ratio) when no
320    // camera/viewport is available yet; recomputed fresh per load so a resized
321    // window is picked up on the next scene load.
322    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        // Parse first; a parse failure aborts only this event.
332        let state = match parse_state(event) {
333            Ok(s) => s,
334            Err(e) => {
335                error_writer.write(e);
336                continue;
337            }
338        };
339
340        // Retire the previous scene. Immediate same-frame despawn+spawn can trip
341        // Bevy's mesh slab allocator during heavy MVS scene switches; hiding and
342        // removing a frame later keeps the old asset handles alive through the
343        // transition while the new scene is already visible.
344        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        // Stash the state for UI inspection (replaces any previous state).
372        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
389// ---------------------------------------------------------------------------
390// Tree traversal
391// ---------------------------------------------------------------------------
392
393fn children(node: &Node) -> &[Node] {
394    node.children.as_deref().unwrap_or(&[])
395}
396
397fn execute_root(root: &Node, ctx: &mut Ctx) {
398    // Canvas and camera are applied first so that any `focus` node encountered later
399    // (deep inside a structure) overrides the camera — focus always wins.
400    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    // download -> parse -> structure
449    for parse in children(node) {
450        if parse.kind != KindT::Parse {
451            continue;
452        }
453        // `format` is informational here; load_model auto-detects from the file.
454        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    // Assembly / symmetry are not expanded; warn and render the plain model.
472    if let Some(NodeParams::StructureParams(sp)) = &node.params {
473        report_unsupported_structure(sp, ctx);
474    }
475
476    // A `transform` node is a sibling of the components and applies to the whole
477    // structure. Collect it first so it can be handed to every component.
478    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    // A component may carry a representation, a label, and/or a focus child.
534    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(&params, 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    // Track this component's world-space AABB in the running scene bounds so a
573    // later, smaller focus() target can be checked against everything actually
574    // rendered, not just itself (ferritin-t0h.5).
575    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    // Collect layered color nodes (applied in document order; later wins).
601    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    // Use the explicit color_theme when present; fall back to ElementSymbol (CPK)
636    // so structures without any Color child nodes get sensible default coloring.
637    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    // Wireframe (MVS "line") geometry is now thin triangle cylinders with real
648    // normals (ferritin-t0h.9), so it can take normal PBR shading like every
649    // other representation instead of being forced unlit.
650    let mut material = StandardMaterial::default();
651    // Surface silently degrades to VdW spheres (Solid); tint it amber so it reads as
652    // a fallback rather than looking pixel-identical to a real Spacefill request —
653    // the `degraded` warning was previously log-only, invisible in the viewer itself.
654    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
729// ---------------------------------------------------------------------------
730// Camera / canvas / focus application
731// ---------------------------------------------------------------------------
732
733fn 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    // The component's own transform (e.g. a superposition's translate) isn't baked
757    // into `ac`'s coordinates, so it must be applied here or focus() targets the
758    // pre-transform location.
759    let (world_min, world_max) = transform_aabb(local_min, local_max, transform);
760
761    // Union with every other focus() target seen so far this load, so a scene with
762    // multiple focused components (e.g. a superposition) frames all of them instead
763    // of just the last one processed.
764    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    // An explicit view direction overrides the orbit orientation. `direction` is the
776    // direction the camera looks *along*, so the camera sits opposite to it. Resolve
777    // this before fitting the radius, since the fit depends on the final yaw/pitch.
778    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    // Clamp against the whole rendered scene, not just the focus target: a tiny
788    // target (e.g. two ions) deep inside a much larger structure would otherwise
789    // pull the camera in close enough that the surrounding, still-rendered
790    // geometry near-clips (ferritin-t0h.5). Re-running the same view-projected
791    // fit around the *focus* center but over the scene's full extent gives the
792    // distance needed to clear all of it; when focus == scene (the common
793    // whole-structure case) the two radii coincide, so this never zooms out
794    // beyond what ferritin-ala.5 already tightened.
795    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
809/// Transform an axis-aligned local `[min, max]` box into world space, accounting
810/// for rotation by transforming all 8 corners rather than just the two extremes
811/// (a rotation can change which corner is extremal on a given axis).
812fn 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
827/// Camera distance that keeps the AABB `[min, max]` fully in view for the current
828/// orbit yaw/pitch/up, centered on the AABB's own midpoint.
829///
830/// Using the worst-case any-angle bounding sphere (`aabb_diagonal / 2`) over-estimates
831/// how far back the camera needs to be for any real (non-spherical) structure, since
832/// the 3D corner-to-corner diagonal is longer than the object's extent along any single
833/// screen axis — this left single structures filling only a small fraction of the
834/// viewport (ferritin-ala.5). Projecting the AABB corners onto the camera's actual
835/// right/up axes gives a much tighter fit for the current view direction.
836fn 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
841/// As [`fit_radius_for_view`], but projects the AABB corners relative to an
842/// explicit `center` rather than the AABB's own midpoint. Used to ask "how far
843/// back must the camera sit, still looking at `center`, to keep this other AABB
844/// in view" — e.g. clearing the whole scene while focused on a small target
845/// within it (ferritin-t0h.5).
846///
847/// `aspect` (viewport width / height) determines the true horizontal FOV; using
848/// the vertical FOV for both axes over-estimated the distance needed to fit the
849/// horizontal extent on a widescreen viewport, leaving whole-structure framing
850/// filling under half the frame (ferritin-t0h.10).
851fn fit_radius_for_view_from_center(
852    center: Vec3,
853    min: Vec3,
854    max: Vec3,
855    orbit: &OrbitCamera,
856    aspect: f32,
857) -> f32 {
858    // Points from focus toward the camera (same convention as `orbit_position`'s
859    // offset); only its axis matters for building a look-at basis, not its sign.
860    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    // Half of Bevy's default *vertical* FOV (45°), used by every Camera3d in this
886    // codebase. The horizontal half-FOV is derived from it via the viewport
887    // aspect ratio (standard perspective-projection relation), not assumed equal
888    // to the vertical one (ferritin-t0h.10).
889    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
915// ---------------------------------------------------------------------------
916// Pure helpers (unit tested)
917// ---------------------------------------------------------------------------
918
919/// `true` if `url` is an HTTP(S) URL (deferred in the MVP); otherwise treat as a
920/// local filesystem path.
921fn is_http_url(url: &str) -> bool {
922    let u = url.trim_start();
923    u.starts_with("http://") || u.starts_with("https://")
924}
925
926/// Map a MVS representation type to a renderer. The bool is `true` when the type was
927/// degraded (Surface → Solid) and a warning should be surfaced.
928fn 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
939/// Parse a [`TransformParams`] into a Bevy [`Transform`]. The rotation, when present,
940/// must be a 9-element column-major 3×3 matrix; any other length is an error.
941fn parse_transform(params: &TransformParams) -> Result<Transform, ()> {
942    let mut transform = Transform::IDENTITY;
943    if let Some(rot) = &params.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) = &params.translation {
961        transform.translation = tuple_to_vec3(*t);
962    }
963    Ok(transform)
964}
965
966/// Axis-aligned bounding box of the masked atoms: returns `(center, diagonal_length)`.
967/// `None` when no atom passes the mask.
968/// Axis-aligned bounding box (min, max) of the masked atoms; `None` when no atom passes.
969fn 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
988/// Mean position of the masked atoms; `None` when no atom passes the mask.
989fn 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    // ---------------------------------------------------------------------------
1042    // Headless integration test helpers
1043    // ---------------------------------------------------------------------------
1044
1045    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    /// Minimal MVSJ with one structure component using `ball_and_stick` (no residue map needed).
1089    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    // --- Pure-helper unit tests (T3-01 .. T3-09) ---------------------------
1121
1122    #[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    /// 3 atoms at (0,0,0), (2,0,0), (1,3,0); all selected.
1136    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(&params), 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(&params).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    // --- Headless Bevy integration tests (T3-10 .. T3-31) ------------------
1266
1267    // T3-10: A minimal valid state spawns exactly one mesh entity.
1268    #[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    // T3-11: Two components (protein + water) with a focus node → 2 meshes.
1281    #[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    // T3-13: A label node spawns an MvsLabel entity.
1332    #[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    // ferritin-ala.3: a label's world anchor gets a companion billboard Text/UiNode
1374    // once a Camera3d exists, positioned via the camera's viewport projection.
1375    #[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        // MinimalPlugins doesn't run the camera-projection update system that
1420        // normally populates `Camera::computed`, so seed it manually (mirrors
1421        // bevy_camera's own internal `make_camera` test helper).
1422        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        // execute_mvs_on_load and update_mvs_label_billboards are `.chain()`d, so a
1436        // single update() already runs both; run one more to let GlobalTransform
1437        // propagation (if any plugin performs it) settle before re-checking.
1438        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    // ferritin-t0h.9: Line/Wireframe geometry is now thin triangle cylinders with
1456    // real normals (no longer a normal-less GPU LineList), so it renders lit like
1457    // every other representation instead of being forced unlit.
1458    #[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    // ferritin-ala.7: Surface silently degrades to VdW spheres (Solid) with only a
1502    // log warning -- the viewer itself gave no visual signal that it wasn't a real
1503    // surface render, pixel-identical to Spacefill. It should now be tinted.
1504    #[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    // ferritin-ala.9: the Surface degradation must reach the MvsError message
1526    // channel (which a status bar subscribes to — see molviewspec_viewer.rs's
1527    // collect_errors/format_error), not just a log-only warn!() call.
1528    #[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    // T3-14: A focus node repositions OrbitCamera away from its default.
1570    #[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        // 101m.cif atoms are not all at origin; focus must shift the radius.
1607        assert_ne!(
1608            orbit.radius, default_orbit.radius,
1609            "T3-14: focus should change orbit radius"
1610        );
1611    }
1612
1613    // ferritin-t0h.5: focusing a tiny target (e.g. a single residue, standing in
1614    // for "two ions") that sits inside a much larger *already-rendered* structure
1615    // must not zoom the camera in tight enough to near-clip through the
1616    // surrounding geometry. Compares the same tiny focus target in isolation
1617    // (nothing else rendered) against the same target focused alongside a full
1618    // cartoon of the whole protein: the latter's radius must be pulled back
1619    // well beyond what the tiny AABB alone would ever justify.
1620    #[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        // Scene A: only the tiny target (first residue) is rendered and focused.
1627        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        // Scene B: the whole protein is rendered (cartoon, unfocused) first, then
1660        // the same tiny residue is focused — mirroring preset_label's structure
1661        // (full protein cartoon, then a small focused ion/label target).
1662        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    // ferritin-t0h.10: on a widescreen viewport, a horizontally-dominant AABB
1712    // should need a *smaller* radius once the true (wider) horizontal FOV is
1713    // used, instead of being fit as if the horizontal FOV equalled the narrower
1714    // vertical one. A square aspect ratio (1:1) is the boundary case where
1715    // horizontal and vertical FOV coincide, so it acts as the "old behavior"
1716    // reference point here.
1717    #[test]
1718    fn test_fit_radius_tighter_on_widescreen_for_wide_aabb() {
1719        let orbit = OrbitCamera::default();
1720        // Wide, flat AABB: extent along the camera's right axis dominates.
1721        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    // ferritin-t0h.10 (contrast case): a vertically-dominant AABB is bounded by
1735    // the *vertical* FOV regardless of aspect ratio, so widening the viewport
1736    // must not change the fitted radius at all.
1737    #[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    // ferritin-ala.5: focus() should fill most of the viewport, not just a small
1754    // fraction of it. Reconstructs the same camera the viewer examples use
1755    // (Camera3d::default(), orbit_position formula) and projects the real rendered
1756    // mesh's vertices to check on-screen coverage, rather than trusting the
1757    // orbit-radius formula's own math (which is exactly what was wrong before).
1758    #[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(&gt, 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    // T3-15: A canvas node sets ClearColor.
1851    #[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        // red → (1,0,0,1)
1867        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    // ferritin-ala.8/ferritin-c1k.4: MvsPlugin should install molecular-viewer
1875    // camera defaults for color fidelity and depth cues.
1876    #[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    // T3-16: A camera node sets OrbitCamera focus/radius.
1909    #[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    // T3-17: HTTP URL emits UnsupportedNode, no meshes spawned.
1939    #[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    // T3-18: Bad file path emits FileNotFound, no meshes spawned.
1969    #[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    // T3-19: Empty selector emits NoAtomsSelected but sibling component continues.
1999    #[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        // "nucleic" selector on 101m.cif (a myoglobin) should match zero atoms.
2004        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        // The sibling "all" component must still render.
2046        assert_eq!(
2047            count_meshes(&mut app),
2048            1,
2049            "T3-19: sibling component must still render"
2050        );
2051    }
2052
2053    // T3-20: Assembly structure type emits UnsupportedNode but still renders the asymmetric unit.
2054    #[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    // T3-21: Symmetry structure type emits UnsupportedNode but renders.
2104    #[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    // T3-22: component_from_uri emits UnsupportedNode and execution continues.
2150    #[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    // T3-23: Scene clear on reload — second load replaces first.
2207    #[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        // First load.
2215        send_and_update(&mut app, &json);
2216        assert_eq!(count_meshes(&mut app), 1, "T3-23: first load spawns 1 mesh");
2217
2218        // Second load with a different (water) selector.
2219        let json2 = mvsj_one_component(&path, "water", "ball_and_stick");
2220        send_and_update(&mut app, &json2);
2221        // Active scene count stays at 1; the old mesh remains hidden briefly to
2222        // avoid same-frame render-allocator free/reallocate churn.
2223        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    // T3-24: A generic_visuals sphere spawns exactly one MvsEntity.
2243    #[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    // T3-25: A generic_visuals line spawns exactly one MvsEntity.
2273    #[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    // T3-26: A transform node applies to all components (transform is non-identity).
2304    #[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        // The mesh should be spawned (transform is valid identity-ish rotation + translation).
2345        assert_eq!(
2346            count_meshes(&mut app),
2347            1,
2348            "T3-26: transform + component → 1 mesh"
2349        );
2350        // Verify no InvalidTransform error.
2351        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    // T3-27: No transform node → identity transform (no error, mesh spawned).
2361    #[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    // T3-28: Two download nodes → 2 meshes total.
2385    #[test]
2386    fn test_two_downloads_two_meshes() {
2387        use ferritin_test_data::TestFile;
2388        // Both must be CIF files; load_model does not support PDB.
2389        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    // ferritin-ala.5: a superposition-style scene with two focus()ed structures
2441    // should frame the union of both, not just the last one processed (which
2442    // previously clipped the first/earlier structure out of frame).
2443    #[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        // Second copy translated 300 units along X: if only the last focus() won,
2449        // orbit.focus.x would land near 300, not near the midpoint (~150).
2450        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    // T3-29: MvsStateResource is populated after a successful load.
2520    #[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    // T3-30: MvsStateResource is replaced on second load.
2537    #[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    // T3-31: Camera node followed by a focus node → focus wins (radius differs from camera).
2558    #[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        // Camera sets radius=1000 (far target); focus will override with a tighter AABB radius.
2563        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        // Focus-computed radius from AABB of 101m.cif will be << 1000.
2605        assert!(
2606            orbit.radius < 500.0,
2607            "T3-31: focus must override camera; radius={} should be << 1000",
2608            orbit.radius
2609        );
2610    }
2611}