fix(pick): require all sub-geometry inside window selection for multi-wire and mesh entities

This commit is contained in:
Karim Jerbi committed 2026-09-08 15:49:54 +01:00
1 parent 4972bbed70
commit 058cd02042
2 files changed
+387 -151

No files matched your search

+100 -64
View File
@@ -8342,24 +8342,33 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else { let Some(indices) = lookup.get(&handle) else {
continue; continue;
}; };
if indices let test = |set: &MeshLodSet| {
.iter() set.geometry_lods().first().is_some_and(|mesh| {
.filter_map(|&index| meshes.get(index as usize)) !pick::hit_test::mesh_box_hit(
.any(|set| { a,
set.geometry_lods().first().is_some_and(|mesh| { b,
!pick::hit_test::mesh_box_hit( crossing,
a, std::iter::once((handle, mesh, set.instance_transform)),
b, view_rot,
crossing, eye,
std::iter::once((handle, mesh, set.instance_transform)), bounds,
view_rot, )
eye, .is_empty()
bounds,
)
.is_empty()
})
}) })
{ };
let mut sets = indices
.iter()
.filter_map(|&index| meshes.get(index as usize));
let hit = if crossing {
sets.any(test)
} else {
let mut count = 0;
sets.all(|set| {
count += 1;
test(set)
}) && count > 0
};
if hit {
out.push(handle); out.push(handle);
} }
} }
@@ -8510,23 +8519,32 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else { let Some(indices) = lookup.get(&handle) else {
continue; continue;
}; };
if indices let test = |set: &MeshLodSet| {
.iter() set.geometry_lods().first().is_some_and(|mesh| {
.filter_map(|&index| meshes.get(index as usize)) !pick::hit_test::mesh_poly_hit(
.any(|set| { poly,
set.geometry_lods().first().is_some_and(|mesh| { crossing,
!pick::hit_test::mesh_poly_hit( std::iter::once((handle, mesh, set.instance_transform)),
poly, view_rot,
crossing, eye,
std::iter::once((handle, mesh, set.instance_transform)), bounds,
view_rot, )
eye, .is_empty()
bounds,
)
.is_empty()
})
}) })
{ };
let mut sets = indices
.iter()
.filter_map(|&index| meshes.get(index as usize));
let hit = if crossing {
sets.any(test)
} else {
let mut count = 0;
sets.all(|set| {
count += 1;
test(set)
}) && count > 0
};
if hit {
out.push(handle); out.push(handle);
} }
} }
@@ -8768,23 +8786,32 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else { let Some(indices) = lookup.get(&handle) else {
continue; continue;
}; };
let hit = indices let test = |set: &MeshLodSet| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_box_hit(
a,
b,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
};
let mut sets = indices
.iter() .iter()
.filter_map(|&index| meshes.get(index as usize)) .filter_map(|&index| meshes.get(index as usize));
.any(|set| { let hit = if crossing {
set.geometry_lods().first().is_some_and(|mesh| { sets.any(test)
!pick::hit_test::mesh_box_hit( } else {
a, let mut count = 0;
b, sets.all(|set| {
crossing, count += 1;
std::iter::once((handle, mesh, set.instance_transform)), test(set)
view_rot, }) && count > 0
eye, };
bounds,
)
.is_empty()
})
});
if hit { if hit {
out.push(handle); out.push(handle);
} }
@@ -8819,22 +8846,31 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else { let Some(indices) = lookup.get(&handle) else {
continue; continue;
}; };
let hit = indices let test = |set: &MeshLodSet| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_poly_hit(
poly,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
};
let mut sets = indices
.iter() .iter()
.filter_map(|&index| meshes.get(index as usize)) .filter_map(|&index| meshes.get(index as usize));
.any(|set| { let hit = if crossing {
set.geometry_lods().first().is_some_and(|mesh| { sets.any(test)
!pick::hit_test::mesh_poly_hit( } else {
poly, let mut count = 0;
crossing, sets.all(|set| {
std::iter::once((handle, mesh, set.instance_transform)), count += 1;
view_rot, test(set)
eye, }) && count > 0
bounds, };
)
.is_empty()
})
});
if hit { if hit {
out.push(handle); out.push(handle);
} }
+287 -87
View File
@@ -1615,10 +1615,10 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
); );
} }
// Q: lazy projection — accumulate screen points without allocating per-wire Vec. if crossing {
wires let mut out = Vec::new();
.iter() let mut seen = HashSet::default();
.filter_map(|wire| { for wire in wires.iter() {
// Fallback: when wire has no line geometry (e.g. greek text emits // Fallback: when wire has no line geometry (e.g. greek text emits
// only fill_tris) treat the AABB rectangle as the hit-test shape // only fill_tris) treat the AABB rectangle as the hit-test shape
// so low-LOD text stays selectable. See #19. // so low-LOD text stays selectable. See #19.
@@ -1639,7 +1639,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
]; ];
&aabb_pts &aabb_pts
} else { } else {
return None; continue;
}; };
// Low residual parallel to `pts` (empty for the AABB fallback, // Low residual parallel to `pts` (empty for the AABB fallback,
@@ -1650,6 +1650,85 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
&[] &[]
}; };
let mut hit = false; let mut hit = false;
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in pts.iter().enumerate() {
if px.is_nan() {
prev = None;
continue;
}
let world = if !wire.points.is_empty() {
wire_point_world(wire, i, view_rot, eye)
} else {
wp64([px, py, pz], low, i)
};
let sp = world_to_screen(world, view_rot, eye, bounds);
if inside(sp) {
hit = true;
}
if let Some(p0) = prev {
if !hit {
hit = segments_intersect(p0, sp, box_tl, box_tr)
|| segments_intersect(p0, sp, box_tr, box_br)
|| segments_intersect(p0, sp, box_br, box_bl)
|| segments_intersect(p0, sp, box_bl, box_tl);
}
}
prev = Some(sp);
}
let mut glyph_crosses = false;
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue;
};
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_box(
[screen[offset], screen[offset + 1], screen[offset + 2]],
box_corners,
)
}) {
glyph_crosses = true;
break;
}
}
if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
}
}
out
} else {
let mut qualified = Vec::new();
let mut disqualified = HashSet::default();
let mut seen = HashSet::default();
for wire in wires.iter() {
let aabb_pts: Vec<[f32; 3]>;
let empty_pts: [[f32; 3]; 0] = [];
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points
} else if !wire.text_verts.is_empty() {
&empty_pts
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
let [ax, ay, bx, by] = wire.aabb;
aabb_pts = vec![
[ax, ay, 0.0],
[bx, ay, 0.0],
[bx, by, 0.0],
[ax, by, 0.0],
[ax, ay, 0.0],
];
&aabb_pts
} else {
continue;
};
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut all_inside = true; let mut all_inside = true;
let mut prev: Option<Point> = None; let mut prev: Option<Point> = None;
@@ -1664,61 +1743,42 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
wp64([px, py, pz], low, i) wp64([px, py, pz], low, i)
}; };
let sp = world_to_screen(world, view_rot, eye, bounds); let sp = world_to_screen(world, view_rot, eye, bounds);
if crossing { if !inside(sp) {
if inside(sp) { all_inside = false;
hit = true;
}
if let Some(p0) = prev {
if !hit {
hit = segments_intersect(p0, sp, box_tl, box_tr)
|| segments_intersect(p0, sp, box_tr, box_br)
|| segments_intersect(p0, sp, box_br, box_bl)
|| segments_intersect(p0, sp, box_bl, box_tl);
}
}
} else {
if !inside(sp) {
all_inside = false;
}
} }
prev = Some(sp); prev = Some(sp);
} }
let glyphs_present = !wire.text_verts.is_empty(); let glyphs_present = !wire.text_verts.is_empty();
let mut glyph_crosses = false;
let mut glyphs_inside = true; let mut glyphs_inside = true;
for start in (0..wire.text_verts.len()).step_by(6) { for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue; continue;
}; };
if crossing { if !screen.iter().copied().all(inside) {
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_box(
[screen[offset], screen[offset + 1], screen[offset + 2]],
box_corners,
)
}) {
glyph_crosses = true;
break;
}
} else if !screen.iter().copied().all(inside) {
glyphs_inside = false; glyphs_inside = false;
break; break;
} }
} }
let result = if crossing { let has_geom = prev.is_some() || glyphs_present;
hit || glyph_crosses if !has_geom {
} else { continue;
all_inside && glyphs_inside && (prev.is_some() || glyphs_present)
};
if result {
Some(wire.name.as_str())
} else {
None
} }
})
.collect() let name = wire.name.as_str();
if all_inside && glyphs_inside {
if seen.insert(name) {
qualified.push(name);
}
} else {
disqualified.insert(name);
}
}
qualified.retain(|name| !disqualified.contains(name));
qualified
}
} }
// ── Polygon / lasso selection ───────────────────────────────────────────── // ── Polygon / lasso selection ─────────────────────────────────────────────
@@ -1743,10 +1803,10 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
return indexed_polygon_crossing_hits(wires, poly, view_rot, eye, bounds); return indexed_polygon_crossing_hits(wires, poly, view_rot, eye, bounds);
} }
// Q: lazy projection — no Vec allocation per wire. if crossing {
wires let mut out = Vec::new();
.iter() let mut seen = HashSet::default();
.filter_map(|wire| { for wire in wires.iter() {
// Same AABB fallback as `box_hit`: when a wire has no line // Same AABB fallback as `box_hit`: when a wire has no line
// geometry (e.g. greek-LOD text emits only fill_tris) treat the // geometry (e.g. greek-LOD text emits only fill_tris) treat the
// AABB rectangle as the hit-test shape so low-LOD text stays // AABB rectangle as the hit-test shape so low-LOD text stays
@@ -1768,7 +1828,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
]; ];
&aabb_pts &aabb_pts
} else { } else {
return None; continue;
}; };
let low: &[[f32; 3]] = if !wire.points.is_empty() { let low: &[[f32; 3]] = if !wire.points.is_empty() {
@@ -1777,6 +1837,88 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
&[] &[]
}; };
let mut hit = false; let mut hit = false;
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in pts.iter().enumerate() {
if px.is_nan() {
prev = None;
continue;
}
let world = if !wire.points.is_empty() {
wire_point_world(wire, i, view_rot, eye)
} else {
wp64([px, py, pz], low, i)
};
let sp = world_to_screen(world, view_rot, eye, bounds);
if sp.x < 0.0 || sp.x > bounds.width || sp.y < 0.0 || sp.y > bounds.height {
prev = None;
continue;
}
if point_in_polygon(sp, poly) {
hit = true;
}
if !hit {
if let Some(p0) = prev {
if segment_crosses_polygon(p0, sp, poly) {
hit = true;
}
}
}
prev = Some(sp);
}
let mut glyph_crosses = false;
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue;
};
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_polygon(
[screen[offset], screen[offset + 1], screen[offset + 2]],
poly,
)
}) {
glyph_crosses = true;
break;
}
}
if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
}
}
out
} else {
let mut qualified = Vec::new();
let mut disqualified = HashSet::default();
let mut seen = HashSet::default();
for wire in wires.iter() {
let aabb_pts: Vec<[f32; 3]>;
let empty_pts: [[f32; 3]; 0] = [];
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points
} else if !wire.text_verts.is_empty() {
&empty_pts
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
let [ax, ay, bx, by] = wire.aabb;
aabb_pts = vec![
[ax, ay, 0.0],
[bx, ay, 0.0],
[bx, by, 0.0],
[ax, by, 0.0],
[ax, ay, 0.0],
];
&aabb_pts
} else {
continue;
};
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut all_inside = true; let mut all_inside = true;
let mut prev: Option<Point> = None; let mut prev: Option<Point> = None;
@@ -1798,43 +1940,19 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
prev = None; prev = None;
continue; continue;
} }
if crossing { if !point_in_polygon(sp, poly) {
if point_in_polygon(sp, poly) { all_inside = false;
hit = true;
}
if !hit {
if let Some(p0) = prev {
if segment_crosses_polygon(p0, sp, poly) {
hit = true;
}
}
}
} else {
if !point_in_polygon(sp, poly) {
all_inside = false;
}
} }
prev = Some(sp); prev = Some(sp);
} }
let glyphs_present = !wire.text_verts.is_empty(); let glyphs_present = !wire.text_verts.is_empty();
let mut glyph_crosses = false;
let mut glyphs_inside = true; let mut glyphs_inside = true;
for start in (0..wire.text_verts.len()).step_by(6) { for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue; continue;
}; };
if crossing { if !screen.iter().copied().all(|point| {
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_polygon(
[screen[offset], screen[offset + 1], screen[offset + 2]],
poly,
)
}) {
glyph_crosses = true;
break;
}
} else if !screen.iter().copied().all(|point| {
point.x >= 0.0 point.x >= 0.0
&& point.x <= bounds.width && point.x <= bounds.width
&& point.y >= 0.0 && point.y >= 0.0
@@ -1846,18 +1964,24 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
} }
} }
let result = if crossing { let has_geom = prev.is_some() || glyphs_present;
hit || glyph_crosses if !has_geom {
} else { continue;
all_inside && glyphs_inside && (prev.is_some() || glyphs_present)
};
if result {
Some(wire.name.as_str())
} else {
None
} }
})
.collect() let name = wire.name.as_str();
if all_inside && glyphs_inside {
if seen.insert(name) {
qualified.push(name);
}
} else {
disqualified.insert(name);
}
}
qualified.retain(|name| !disqualified.contains(name));
qualified
}
} }
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
@@ -2511,4 +2635,80 @@ mod aabb_reject_tests {
vec!["479"], vec!["479"],
); );
} }
#[test]
fn window_selection_multi_wire_entity_behavior() {
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
};
// In identity ortho view:
// world (0.0, 0.0) -> screen (100.0, 100.0)
// world (0.1, 0.1) -> screen (110.0, 90.0)
// world (0.8, 0.8) -> screen (180.0, 20.0)
let wire_arc = wire(
"4F",
vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
[0.0, 0.0, 0.1, 0.1],
);
let wire_lines = wire(
"4F",
vec![[0.1, 0.1, 0.0], [0.8, 0.8, 0.0]],
[0.1, 0.1, 0.8, 0.8],
);
let wires = [wire_arc, wire_lines];
// Box enclosing ONLY wire_arc (screen 95..115, 85..105):
let box_a = Point::new(95.0, 85.0);
let box_b = Point::new(115.0, 105.0);
let poly_small = [
Point::new(95.0, 85.0),
Point::new(115.0, 85.0),
Point::new(115.0, 105.0),
Point::new(95.0, 105.0),
];
// Crossing mode selects "4F" because wire_arc is inside:
assert_eq!(
box_hit(box_a, box_b, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
assert_eq!(
poly_hit(&poly_small, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
// Window mode MUST NOT select "4F" because wire_lines has a vertex at (0.8, 0.8) -> (180.0, 20.0) outside:
assert_eq!(
box_hit(box_a, box_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
Vec::<&str>::new()
);
assert_eq!(
poly_hit(&poly_small, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
Vec::<&str>::new()
);
// Big box enclosing BOTH wire_arc and wire_lines (screen 50..190, 10..110):
let big_a = Point::new(50.0, 10.0);
let big_b = Point::new(190.0, 110.0);
let poly_big = [
Point::new(50.0, 10.0),
Point::new(190.0, 10.0),
Point::new(190.0, 110.0),
Point::new(50.0, 110.0),
];
// Window mode now selects "4F" once (deduplicated):
assert_eq!(
box_hit(big_a, big_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
assert_eq!(
poly_hit(&poly_big, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
}
} }