fix(groups): highlight full group on hover

Refs #454
This commit is contained in:
Hakan Seven 2026-08-04 16:40:22 +03:00
commit 282a5176af
4 changed files with 77 additions and 47 deletions

View file

@ -175,23 +175,36 @@ impl Scene {
count count
} }
/// If `handle` belongs to any selectable groups, also select all other members of those groups. /// Return `handles` plus every member of a selectable group containing one
/// of them. Selection and rollover highlighting share this expansion so the
/// preview matches what a click will select.
pub fn handles_expanded_for_selectable_groups(
&self,
handles: &[Handle],
) -> HashSet<Handle> {
let mut expanded: HashSet<Handle> = handles.iter().copied().collect();
expanded.extend(
self.document
.objects
.values()
.filter_map(|obj| match obj {
ObjectType::Group(g)
if g.selectable
&& handles.iter().any(|handle| g.contains(*handle)) =>
{
Some(g.entities.clone())
}
_ => None,
})
.flatten(),
);
expanded
}
/// If any handle belongs to a selectable group, also select every member.
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) { pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
let to_add: Vec<Handle> = self self.selected
.document .extend(self.handles_expanded_for_selectable_groups(handles));
.objects
.values()
.filter_map(|obj| match obj {
ObjectType::Group(g) if g.selectable && handles.iter().any(|h| g.contains(*h)) => {
Some(g.entities.clone())
}
_ => None,
})
.flatten()
.collect();
for h in to_add {
self.selected.insert(h);
}
self.bump_selection(); self.bump_selection();
} }
} }

View file

@ -4235,18 +4235,33 @@ impl Scene {
if self.hover_highlight == handle { if self.hover_highlight == handle {
return; return;
} }
// Hover is folded into the highlight set (selected ∪ {hover}) that // Selectable groups roll over as one unit, matching click selection.
// drives the xray overlay. A hover handle that's already selected // Exclude already-selected members because they already contribute to
// contributes nothing, so the effective set is unchanged — skip the // the overlay and remain in the selected colour.
// overlay refresh then. The field is still updated for hit-test / UI. let contribution = |scene: &Scene, hovered: Option<Handle>| {
let contribution = |h: Option<Handle>| h.filter(|h| !self.selected.contains(h)); hovered
let changed = contribution(self.hover_highlight) != contribution(handle); .map(|handle| scene.handles_expanded_for_selectable_groups(&[handle]))
.unwrap_or_default()
.into_iter()
.filter(|handle| !scene.selected.contains(handle))
.collect::<HashSet<_>>()
};
let changed = contribution(self, self.hover_highlight) != contribution(self, handle);
self.hover_highlight = handle; self.hover_highlight = handle;
if changed { if changed {
self.bump_selection(); self.bump_selection();
} }
} }
/// Handles that currently contribute the rollover colour. The stored hover
/// remains the picked member for hit-test/UI bookkeeping; rendering expands
/// it to the same selectable group that a click would select.
pub fn hover_highlight_handles(&self) -> HashSet<Handle> {
self.hover_highlight
.map(|handle| self.handles_expanded_for_selectable_groups(&[handle]))
.unwrap_or_default()
}
/// Keep the current selection visible and temporarily filter every other /// Keep the current selection visible and temporarily filter every other
/// top-level object. Block-definition children are intentionally not added /// top-level object. Block-definition children are intentionally not added
/// here: an isolated INSERT remains a complete visible block instance. /// here: an isolated INSERT remains a complete visible block instance.

View file

@ -1793,13 +1793,12 @@ impl Pipeline {
device: &wgpu::Device, device: &wgpu::Device,
wires: &[WireModel], wires: &[WireModel],
selected: &rustc_hash::FxHashSet<acadrust::Handle>, selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>, hovered: &rustc_hash::FxHashSet<acadrust::Handle>,
annotation_context_wires: &[WireModel], annotation_context_wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>, depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) { ) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now); let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
let hover = hover.filter(|h| !selected.contains(h)); if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() {
if selected.is_empty() && hover.is_none() && annotation_context_wires.is_empty() {
self.gpu_selected_wires = vec![]; self.gpu_selected_wires = vec![];
return; return;
} }
@ -1818,7 +1817,7 @@ impl Pipeline {
} }
} }
} }
if let Some(h) = hover { for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) { if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone(); let mut slots = idxs.clone();
slots.sort_unstable(); slots.sort_unstable();
@ -1857,7 +1856,11 @@ impl Pipeline {
crate::perf_record!( crate::perf_record!(
"[perf] wire-highlight {:>7.1}ms handles={} wires={}", "[perf] wire-highlight {:>7.1}ms handles={} wires={}",
elapsed_ms, elapsed_ms,
selected.len() + usize::from(hover.is_some()), selected.len()
+ hovered
.iter()
.filter(|handle| !selected.contains(handle))
.count(),
selected_wires.len() + hover_wires.len(), selected_wires.len() + hover_wires.len(),
); );
} }
@ -1873,12 +1876,11 @@ impl Pipeline {
device: &wgpu::Device, device: &wgpu::Device,
wires: &[WireModel], wires: &[WireModel],
selected: &rustc_hash::FxHashSet<acadrust::Handle>, selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>, hovered: &rustc_hash::FxHashSet<acadrust::Handle>,
annotation_context_wires: &[WireModel], annotation_context_wires: &[WireModel],
) { ) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now); let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
let hover = hover.filter(|h| !selected.contains(h)); if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() {
if selected.is_empty() && hover.is_none() && annotation_context_wires.is_empty() {
self.text_highlight_vbuf = None; self.text_highlight_vbuf = None;
self.text_highlight_vcount = 0; self.text_highlight_vcount = 0;
return; return;
@ -1902,7 +1904,7 @@ impl Pipeline {
for h in selected { for h in selected {
push(h.value(), WireModel::SELECTED, wires, &mut out); push(h.value(), WireModel::SELECTED, wires, &mut out);
} }
if let Some(h) = hover { for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
push(h.value(), WireModel::HOVER, wires, &mut out); push(h.value(), WireModel::HOVER, wires, &mut out);
} }
for wire in annotation_context_wires { for wire in annotation_context_wires {
@ -2638,7 +2640,7 @@ impl Pipeline {
pub fn update_mesh_highlight( pub fn update_mesh_highlight(
&mut self, &mut self,
selected: &rustc_hash::FxHashSet<acadrust::Handle>, selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>, hovered: &rustc_hash::FxHashSet<acadrust::Handle>,
) { ) {
let mut out = Vec::new(); let mut out = Vec::new();
for handle in selected { for handle in selected {
@ -2649,7 +2651,7 @@ impl Pipeline {
})); }));
} }
} }
if let Some(handle) = hover.filter(|handle| !selected.contains(handle)) { for handle in hovered.iter().filter(|handle| !selected.contains(handle)) {
if let Some(ranges) = self.mesh_ranges_by_handle.get(&handle) { if let Some(ranges) = self.mesh_ranges_by_handle.get(&handle) {
out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw { out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw {
range, range,

View file

@ -137,8 +137,8 @@ pub struct ViewportData {
pub(in crate::scene) wire_patch: Option<(u64, Arc<crate::scene::WireGpuPatch>)>, pub(in crate::scene) wire_patch: Option<(u64, Arc<crate::scene::WireGpuPatch>)>,
/// Selected handles only (no hover) — solid meshes tint these blue. /// Selected handles only (no hover) — solid meshes tint these blue.
pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>, pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
/// Currently hovered handle — solid meshes tint it orange. /// Currently hovered selectable unit — solid meshes tint it orange.
pub(in crate::scene) hover_handle: Option<acadrust::Handle>, pub(in crate::scene) hover_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
/// Bumped on selection / hover change. Paired with `wire_content_id` to /// Bumped on selection / hover change. Paired with `wire_content_id` to
/// decide when the xray overlay batch needs rebuilding. /// decide when the xray overlay batch needs rebuilding.
pub(in crate::scene) selection_generation: u64, pub(in crate::scene) selection_generation: u64,
@ -789,12 +789,12 @@ impl shader::Primitive for Primitive {
// wire buffers. // wire buffers.
let sel_key = (vp.wire_content_id, vp.selection_generation); let sel_key = (vp.wire_content_id, vp.selection_generation);
let highlighted_geometry_unchanged = let highlighted_geometry_unchanged =
vp.selected_handles.is_empty() && vp.hover_handle.is_none() vp.selected_handles.is_empty() && vp.hover_handles.is_empty()
|| vp.wire_patch.as_ref().is_some_and(|(previous, patch)| { || vp.wire_patch.as_ref().is_some_and(|(previous, patch)| {
*previous == inner.cached_selection.0 *previous == inner.cached_selection.0
&& patch.changes.iter().all(|(handle, _)| { && patch.changes.iter().all(|(handle, _)| {
!vp.selected_handles.contains(handle) !vp.selected_handles.contains(handle)
&& vp.hover_handle != Some(*handle) && !vp.hover_handles.contains(handle)
}) })
}); });
let selection_changed = inner.cached_selection.1 != vp.selection_generation; let selection_changed = inner.cached_selection.1 != vp.selection_generation;
@ -813,7 +813,7 @@ impl shader::Primitive for Primitive {
device, device,
&vp_wires[..], &vp_wires[..],
&vp.selected_handles, &vp.selected_handles,
vp.hover_handle, &vp.hover_handles,
&vp.annotation_context_wires, &vp.annotation_context_wires,
&draw_depths, &draw_depths,
); );
@ -824,7 +824,7 @@ impl shader::Primitive for Primitive {
device, device,
&vp_wires[..], &vp_wires[..],
&vp.selected_handles, &vp.selected_handles,
vp.hover_handle, &vp.hover_handles,
&vp.annotation_context_wires, &vp.annotation_context_wires,
); );
inner.cached_annotation_highlight_source = inner.cached_annotation_highlight_source =
@ -864,7 +864,7 @@ impl shader::Primitive for Primitive {
vp.selection_generation, vp.selection_generation,
); );
if hl_key != inner.cached_highlight_key { if hl_key != inner.cached_highlight_key {
inner.update_mesh_highlight(&vp.selected_handles, vp.hover_handle); inner.update_mesh_highlight(&vp.selected_handles, &vp.hover_handles);
inner.cached_highlight_key = hl_key; inner.cached_highlight_key = hl_key;
} }
// Live overlay (command preview / interim / grip drag) — small and // Live overlay (command preview / interim / grip drag) — small and
@ -1978,12 +1978,12 @@ impl Scene {
.copied() .copied()
.map(|handle| (handle, true)) .map(|handle| (handle, true))
.collect(); .collect();
if let Some(handle) = self highlighted.extend(
.hover_highlight self.hover_highlight_handles()
.filter(|handle| !self.selected.contains(handle)) .into_iter()
{ .filter(|handle| !self.selected.contains(handle))
highlighted.push((handle, false)); .map(|handle| (handle, false)),
} );
highlighted.sort_unstable_by_key(|(handle, _)| handle.value()); highlighted.sort_unstable_by_key(|(handle, _)| handle.value());
let empty_selection = rustc_hash::FxHashSet::default(); let empty_selection = rustc_hash::FxHashSet::default();
@ -2581,7 +2581,7 @@ impl Scene {
wire_content_id, wire_content_id,
wire_patch, wire_patch,
selected_handles: Arc::new(self.selected.iter().copied().collect()), selected_handles: Arc::new(self.selected.iter().copied().collect()),
hover_handle: self.hover_highlight, hover_handles: Arc::new(self.hover_highlight_handles()),
selection_generation: self.selection_generation, selection_generation: self.selection_generation,
selected_sig: self.selected_set_sig(), selected_sig: self.selected_set_sig(),
screen_rect, screen_rect,