concinnity-dev 0.19.119

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
//! Picking in the viewport (`hook/pick.rs`): the nearest hit a click resolves
//! to, the cycle a repeat click walks, the clear on empty space, the ring the
//! selection draws, and the rows the session's hide and lock flags make
//! unpickable.

use concinnity_cook::authoring::registry::RegisteredType;
use concinnity_core::components::FrameInput;
use concinnity_core::components::Sprite;
use concinnity_core::ecs::World;
use concinnity_host::thread::asset_id;
use std::collections::BTreeSet;

use super::fixtures::{click_at, entry, hook, pick_world, release_at, set_input};
use crate::editor::hook::FormTarget;
use crate::editor::hook::pick::{camera_ray, ray_hits};
use crate::frame_hook::FrameHook;

use crate::editor::panels::asset_tree::{self, TreeGroup};

use crate::editor::panels::assets_panel;

use crate::editor::hook::tests::fixtures::entry_target;
use crate::editor::hook::tests::fixtures::{active, selected};
use crate::editor::panels::registry::PanelKey;
use crate::editor::sim;
use crate::editor::viewport::highlight;
use crate::test_support::isolate_state_dir;

// A click through the scene picks the nearest hit and nothing more: no
// panel or form opens on a viewport click, so clicking around the scene never
// spawns UI.
#[test]
fn viewport_click_picks_the_nearest_prop_without_opening_a_form() {
    asset_id::reset_interner();
    let near = asset_id::intern("box_near");
    let far = asset_id::intern("box_far");
    let mut world = pick_world(
        [0.0; 3],
        vec![
            // Both boxes straddle the -Z axis; the near one at z ~ -5.
            (far, [-1.0, -1.0, -11.0], [1.0, 1.0, -9.0]),
            (near, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0]),
        ],
    );
    let mut h = hook(vec![
        entry("box_near", "Sprite"),
        entry("box_far", "Sprite"),
    ]);

    click_at(&mut world, &mut h, [640.0, 360.0]);
    assert_eq!(active(&h).as_deref(), Some("box_near"), "nearest hit wins");
    assert!(!h.open[PanelKey::Assets], "a viewport click opens no panel");
    assert!(!h.form_open(), "a viewport click opens no form");

    // An already-open form follows the pick instead.
    h.open_asset_form("box_far", &mut world);
    assert_eq!(h.form.target, entry_target(&h, 1));
    // Off the repeat-click slop, so this is a fresh pick and not a cycle.
    click_at(&mut world, &mut h, [650.0, 370.0]);
    assert_eq!(
        h.form.target,
        entry_target(&h, 0),
        "the open form retargets to the picked entry"
    );
    assert_eq!(h.form.selected_type.as_deref(), Some("Sprite"));
}

// A second click on the same spot cycles to the occluded hit; a click away
// from any box clears the selection and the cycle. The boxes sit down-left of
// the camera axis so the clicks land in the screen region no default panel
// covers (the first pick opens the edit form, which claims center presses).
#[test]
fn repeat_viewport_clicks_cycle_and_empty_space_clears() {
    asset_id::reset_interner();
    let near = asset_id::intern("box_near");
    let far = asset_id::intern("box_far");
    // The ray through pixel [200, 600] (fov 90, 1280x720) passes ~[-6.1, -3.3]
    // at depth 5 and ~[-12.2, -6.7] at depth 10; both boxes straddle it.
    let mut world = pick_world(
        [0.0; 3],
        vec![
            (near, [-7.1, -4.3, -6.0], [-5.1, -2.3, -4.0]),
            (far, [-13.2, -7.7, -11.0], [-11.2, -5.7, -9.0]),
        ],
    );
    let mut h = hook(vec![
        entry("box_near", "Sprite"),
        entry("box_far", "Sprite"),
    ]);

    click_at(&mut world, &mut h, [200.0, 600.0]);
    assert_eq!(active(&h).as_deref(), Some("box_near"));
    click_at(&mut world, &mut h, [201.0, 601.0]);
    assert_eq!(
        active(&h).as_deref(),
        Some("box_far"),
        "a repeat click reaches the occluded box"
    );
    assert_eq!(
        selected(&h).len(),
        1,
        "a plain click replaces, never accumulates"
    );
    click_at(&mut world, &mut h, [200.0, 600.0]);
    assert_eq!(
        active(&h).as_deref(),
        Some("box_near"),
        "the cycle wraps back to the front"
    );

    // Aim into the gutter between the Preview panel and the edit form, well
    // away from both boxes: the press arms a marquee, and the still release
    // clears.
    click_at(&mut world, &mut h, [250.0, 450.0]);
    assert_eq!(
        active(&h).as_deref(),
        Some("box_near"),
        "the selection survives until the release decides click vs marquee"
    );
    release_at(&mut world, &mut h, [250.0, 450.0]);
    assert_eq!(
        active(&h).as_deref(),
        None,
        "empty space clears the selection"
    );
}

// Picking an asset the world does not declare opens no form until the tree
// knows how (if at all) it can be promoted: an unknown name is selectable only.
#[test]
fn viewport_click_on_an_unknown_asset_selects_without_a_form() {
    let _guard = crate::test_support::lock();
    isolate_state_dir();
    asset_id::reset_interner();
    let generated = asset_id::intern("some_generated_asset");
    let mut world = pick_world(
        [0.0; 3],
        vec![(generated, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0])],
    );
    let mut h = hook(vec![entry("box_near", "Sprite")]);

    click_at(&mut world, &mut h, [640.0, 360.0]);
    assert_eq!(active(&h).as_deref(), Some("some_generated_asset"));
    assert!(
        !h.open[PanelKey::Assets],
        "no panel opens on a viewport click"
    );
    assert_eq!(h.form.target, FormTarget::New);
    assert!(!h.form_open());
}

// A history snapshot carries the entries' session keys, so a jump restores the
// very entries the selection addresses and the selection comes with it. The
// pick cycle does not: its hit list was sampled against the pre-jump world.
#[test]
fn a_history_jump_keeps_a_selection_its_entries_survive() {
    asset_id::reset_interner();
    let id = asset_id::intern("box_near");
    let mut world = pick_world([0.0; 3], vec![(id, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0])]);
    let mut h = hook(vec![entry("box_near", "Sprite")]);
    click_at(&mut world, &mut h, [640.0, 360.0]);
    assert_eq!(active(&h).as_deref(), Some("box_near"));

    h.entries.push(entry("b", "Sprite"));
    h.mark_changed();
    h.undo(&mut world);
    assert_eq!(
        active(&h).as_deref(),
        Some("box_near"),
        "undoing an unrelated add leaves the selected entry standing"
    );
    assert!(h.pick_last.is_none(), "the repeat-click cycle is dropped");
}

// The other half of the same rule: a jump that takes the selected entry away
// leaves nothing addressed, rather than sliding the selection onto whatever
// entry took its place.
#[test]
fn a_history_jump_drops_a_member_whose_entry_it_removes() {
    asset_id::reset_interner();
    let id = asset_id::intern("added");
    let mut world = pick_world([0.0; 3], vec![(id, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0])]);
    let mut h = hook(vec![entry("box_near", "Sprite")]);
    h.entries.push(entry("added", "Sprite"));
    h.mark_changed();
    click_at(&mut world, &mut h, [640.0, 360.0]);
    assert_eq!(active(&h).as_deref(), Some("added"));

    h.undo(&mut world);
    assert_eq!(
        active(&h).as_deref(),
        None,
        "the entry the selection addressed is gone"
    );
}

// The selection ring follows the picked asset's projected bounds and hides
// outside edit mode.
#[test]
fn selection_ring_tracks_the_picked_asset() {
    asset_id::reset_interner();
    let id = asset_id::intern("box_near");
    let mut world = pick_world([0.0; 3], vec![(id, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0])]);
    let mut h = hook(vec![entry("box_near", "Sprite")]);
    click_at(&mut world, &mut h, [640.0, 360.0]);

    let ring = |world: &World| {
        world
            .get_by_id::<Sprite>(highlight::ids().sprites[0])
            .cloned()
            .expect("outline sprite injected")
    };
    let s = ring(&world);
    assert!(s.visible, "the ring shows on pick");
    let (cx, cy) = (s.x + s.width * 0.5, s.y + s.height * 0.5);
    assert!(
        (cx - 640.0).abs() < 2.0 && (cy - 360.0).abs() < 2.0,
        "ring centered on the box, got ({cx}, {cy})"
    );
    assert!(s.border_width > 0.0 && s.tint[3] == 0.0, "border-only ring");

    // Play mode hides the ring; returning to edit mode restores it.
    h.sim.state = sim::SimState::Playing;
    set_input(
        &mut world,
        FrameInput {
            viewport: [1280.0, 720.0],
            ..Default::default()
        },
    );
    h.tick(&mut world);
    assert!(!ring(&world).visible, "hidden in play mode");
    h.sim.state = sim::SimState::Stopped;
    h.tick(&mut world);
    assert!(ring(&world).visible, "back in edit mode it returns");
}

// A locked asset is skipped by viewport picking: the click passes through to
// empty space (arming the marquee) instead of selecting it.
#[test]
fn locked_assets_are_skipped_by_viewport_picking() {
    asset_id::reset_interner();
    let near = asset_id::intern("box_near");
    let mut world = pick_world([0.0; 3], vec![(near, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0])]);
    let mut h = hook(vec![entry("box_near", "Sprite")]);
    h.locked_assets.insert(h.handle_for("box_near"));

    click_at(&mut world, &mut h, [640.0, 360.0]);
    assert_eq!(active(&h).as_deref(), None, "the locked box is not picked");
    assert!(h.marquee.is_some(), "the click fell through to empty space");
}

const PICK_VIEWPORT: [f32; 2] = [1280.0, 720.0];
const CENTER_PIXEL: [f32; 2] = [640.0, 360.0];

// A camera-less world has nothing 3D to pick, so no ray is built.
#[test]
fn camera_ray_needs_a_camera() {
    assert!(camera_ray(&World::new(), PICK_VIEWPORT, CENTER_PIXEL).is_none());
}

// The center pixel's ray starts at the camera and looks down its -Z view axis.
#[test]
fn camera_ray_through_the_center_follows_the_view_axis() {
    let world = pick_world([0.0; 3], vec![]);
    let ray = camera_ray(&world, PICK_VIEWPORT, CENTER_PIXEL).expect("the world has a camera");
    assert_eq!(ray.origin, [0.0; 3]);
    let [x, y, z] = ray.dir;
    assert!(
        x.abs() < 1e-2 && y.abs() < 1e-2 && (z + 1.0).abs() < 1e-2,
        "got {:?}",
        ray.dir
    );
}

// Hits come back near-to-far whatever order the PickIndex lists them in.
#[test]
fn ray_hits_orders_near_before_far_regardless_of_index_order() {
    asset_id::reset_interner();
    let near = asset_id::intern("box_near");
    let far = asset_id::intern("box_far");
    let world = pick_world(
        [0.0; 3],
        vec![
            (far, [-1.0, -1.0, -11.0], [1.0, 1.0, -9.0]),
            (near, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0]),
        ],
    );
    let ray = camera_ray(&world, PICK_VIEWPORT, CENTER_PIXEL).unwrap();
    assert_eq!(ray_hits(&world, &ray, &BTreeSet::new()), vec![near, far]);
}

#[test]
fn ray_hits_skips_locked_ids() {
    asset_id::reset_interner();
    let near = asset_id::intern("box_near");
    let far = asset_id::intern("box_far");
    let world = pick_world(
        [0.0; 3],
        vec![
            (near, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0]),
            (far, [-1.0, -1.0, -11.0], [1.0, 1.0, -9.0]),
        ],
    );
    let ray = camera_ray(&world, PICK_VIEWPORT, CENTER_PIXEL).unwrap();
    let locked = BTreeSet::from([near]);
    assert_eq!(ray_hits(&world, &ray, &locked), vec![far]);
}

#[test]
fn ray_hits_is_empty_without_a_pick_index() {
    let ray = camera_ray(&pick_world([0.0; 3], vec![]), PICK_VIEWPORT, CENTER_PIXEL).unwrap();
    assert!(ray_hits(&World::new(), &ray, &BTreeSet::new()).is_empty());
}

#[test]
fn ray_hits_leaves_out_a_box_the_ray_misses() {
    asset_id::reset_interner();
    let near = asset_id::intern("box_near");
    let aside = asset_id::intern("box_aside");
    let world = pick_world(
        [0.0; 3],
        vec![
            (aside, [5.0, -1.0, -6.0], [7.0, 1.0, -4.0]),
            (near, [-1.0, -1.0, -6.0], [1.0, 1.0, -4.0]),
        ],
    );
    let ray = camera_ray(&world, PICK_VIEWPORT, CENTER_PIXEL).unwrap();
    assert_eq!(ray_hits(&world, &ray, &BTreeSet::new()), vec![near]);
}

// A viewport pick unfolds the picked asset's group and scrolls its row into
// the tree's window.
#[test]
fn viewport_pick_reveals_the_tree_row() {
    let world = World::new();
    let mut h = hook(Vec::new());
    h.open[PanelKey::Assets] = true;
    h.assets.stale = false;
    h.assets.groups = vec![TreeGroup {
        label: asset_tree::WORLD_GROUP.to_string(),
        assets: (0..30)
            .map(|i| asset_tree::TreeAsset {
                name: format!("a{i:02}"),
                asset_type: RegisteredType::Sprite,
                badge: asset_tree::Badge::Authored,
                promote: None,
            })
            .collect(),
    }];

    h.reveal_in_tree("a25", &world);
    assert_eq!(h.assets.unfolded, vec![0], "the group unfolds");
    // Rows: header at 0, a25 at 26; the scroll clamps to the last window.
    assert_eq!(h.assets.scroll, 31 - assets_panel::ROW_POOL);

    // A revealed row already inside the window leaves the scroll alone.
    h.reveal_in_tree("a20", &world);
    assert_eq!(h.assets.scroll, 31 - assets_panel::ROW_POOL);
}