1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// src/editor/hook/marquee_drag.rs
//
// EditorHook: the marquee (box-select) drive. A press over empty viewport
// space -- already past the top bar, the panels, and the gizmo handles by the
// time it reaches the pick -- arms a marquee; while the button is held the
// rect follows the cursor once it clears the start slop. Releasing selects
// every asset whose projected AABB intersects the rect (shift adds to the
// selection instead of replacing it); a release inside the slop is the plain
// empty-space click, which clears. Escape cancels. The fly camera and play
// mode capture the cursor, so `left_click` never arms a marquee there.
use super::*;
pub(super) struct MarqueeDrag {
anchor: [f32; 2],
current: [f32; 2],
// Shift at the press: the release adds to the selection instead of
// replacing it (and a still release leaves the selection alone).
additive: bool,
// Latched once the cursor clears the start slop; a still release is an
// empty-space click, not a box select.
moved: bool,
}
impl EditorHook {
pub(super) fn begin_marquee(&mut self, anchor: [f32; 2], additive: bool) {
self.marquee = Some(MarqueeDrag {
anchor,
current: anchor,
additive,
moved: false,
});
}
// Per-frame marquee drive: follow the cursor while the button is held,
// cancel on Escape, select on release.
pub(super) fn drive_marquee(&mut self, input: &FrameInput, vp: [f32; 2], world: &mut World) {
let Some(m) = &mut self.marquee else {
return;
};
if input.escape {
self.marquee = None;
return;
}
m.current = [input.mouse_x, input.mouse_y];
let dx = (m.current[0] - m.anchor[0]).abs();
let dy = (m.current[1] - m.anchor[1]).abs();
if dx.max(dy) >= marquee::DRAG_START_PX {
m.moved = true;
}
if input.left_button_down {
return;
}
let Some(m) = self.marquee.take() else {
return;
};
if !m.moved {
// The plain empty-space click; with shift the selection is kept
// (a missed shift-click must not throw the set away).
if !m.additive {
self.selection.clear();
self.pick_last = None;
}
return;
}
let rect = marquee::rect_from_corners(m.anchor, m.current);
let names = marquee_hits(world, vp, rect, &self.locked_assets);
if m.additive {
self.selection.extend(names);
} else {
self.selection.set(names);
}
self.pick_last = None;
self.follow_active(world);
}
// Show or hide the marquee rect for this frame.
pub(super) fn drive_marquee_draw(&self, world: &mut World, shown: bool) {
match &self.marquee {
Some(m) if shown && m.moved => {
marquee::place(world, marquee::rect_from_corners(m.anchor, m.current));
}
_ => marquee::hide(world),
}
}
}
// The names of every PickIndex entry whose projected AABB intersects `rect`,
// in index order. Entries whose projection fails (behind the camera, off
// screen), whose id no longer resolves, or which are locked against picking
// (the tree's lock) are skipped.
fn marquee_hits(
world: &World,
vp: [f32; 2],
rect: [f32; 4],
locked: &std::collections::BTreeSet<String>,
) -> Vec<String> {
let Some(index) = world.resource::<crate::ecs::PickIndex>() else {
return Vec::new();
};
let Some(cam) = world.query::<crate::components::Camera3D>().next() else {
return Vec::new();
};
let fov = cam.fov_y_degrees.to_radians();
index
.entries
.iter()
.filter_map(|e| {
let r = highlight::screen_rect(&cam.view_matrix, fov, vp, e.bb_min, e.bb_max)?;
if !marquee::rects_intersect(rect, r) {
return None;
}
pick::resolve_name(e.asset_id).filter(|n| !locked.contains(n))
})
.collect()
}