perf: unify spatial interaction queries

Dense drawings previously rescanned full geometry for snapping, tracking, and selection.\n\nRefs #433
This commit is contained in:
Hakan Seven committed 2026-07-24 20:49:05 +03:00
1 parent ece5598372
commit 4db763ef27
8 files changed
+4606 -3425

No files matched your search

+69 -51
View File
@@ -214,7 +214,10 @@ impl OpenCADStudio {
// UCS (relative offsets are rotated by the UCS axes), so a multi-
// token `LINE 0,0 10,10` under a rotated UCS lands correctly.
let ucs = self.tabs[i].active_ucs.clone();
let wcs = match (matches!(kind, super::helpers::CoordKind::Relative), self.last_point) {
let wcs = match (
matches!(kind, super::helpers::CoordKind::Relative),
self.last_point,
) {
(true, Some(base)) => {
base + match &ucs {
Some(u) => super::helpers::ucs_rotate_vec(coord, u),
@@ -257,8 +260,7 @@ impl OpenCADStudio {
{
// Remember the working set for the "Previous" selection keyword
// (#426) before dropping it.
self.tabs[i].prev_selection =
self.tabs[i].scene.selected.iter().copied().collect();
self.tabs[i].prev_selection = self.tabs[i].scene.selected.iter().copied().collect();
self.tabs[i].scene.deselect_all();
self.refresh_properties();
}
@@ -549,8 +551,7 @@ impl OpenCADStudio {
// Same gate as COPY (dimension-free), sized by the total number
// of copies the array will add.
let delta_safe = self.delta_copy_safe(i, &handles);
let pending =
self.begin_undo(i, label.clone(), handles.len() * count, delta_safe);
let pending = self.begin_undo(i, label.clone(), handles.len() * count, delta_safe);
for t in &transforms {
self.tabs[i].scene.copy_entities(&handles, t);
}
@@ -569,8 +570,7 @@ impl OpenCADStudio {
}
CmdResult::ReplaceMany(replacements, additions) => {
let label = self.history_label_from_active_cmd(i, "FILLET");
let was_catchment = self
.tabs[i]
let was_catchment = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.name() == "SS_CATCHMENT");
@@ -1161,7 +1161,9 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].scene.zoom_to_window(p1.as_vec3(), p2.as_vec3());
self.tabs[i]
.scene
.zoom_to_window(p1.as_vec3(), p2.as_vec3());
self.command_line.push_output("Zoom Window");
}
CmdResult::Measurement(msg) => {
@@ -1335,10 +1337,8 @@ impl OpenCADStudio {
.and_then(convert_to_polyline);
match converted {
Some(pl) => {
return self.apply_cmd_result(CmdResult::ReplaceEntity(
handle,
vec![pl],
));
return self
.apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![pl]));
}
None => self
.command_line
@@ -1457,9 +1457,8 @@ impl OpenCADStudio {
let x1 = p1.x.max(p2.x);
let y1 = p1.y.max(p2.y);
self.plot_window = Some((x0, y0, x1, y1));
self.command_line.push_output(&format!(
"Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}"
));
self.command_line
.push_output(&format!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}"));
// Pick window closed the plot dialog so the viewport could
// receive the two clicks — bring the dialog back with the
// window now active.
@@ -1524,23 +1523,14 @@ impl OpenCADStudio {
let mut handles: Vec<Handle> = Vec::new();
{
let scene = &self.tabs[i].scene;
let mut seen: rustc_hash::FxHashSet<Handle> = rustc_hash::FxHashSet::default();
for w in scene.entity_wires().iter() {
let Some(h) = crate::scene::Scene::handle_from_wire_name(&w.name) else {
continue;
};
if !seen.insert(h) || scene.is_layer_locked(h) {
continue;
}
let [ax, ay, bx, by] = w.aabb;
if ax as f64 <= win_max.x
&& bx as f64 >= win_min.x
&& ay as f64 <= win_max.y
&& by as f64 >= win_min.y
{
handles.push(h);
}
}
handles.extend(
scene
.interaction_handles_in_world_aabb([
win_min.x, win_min.y, win_max.x, win_max.y,
])
.into_iter()
.filter(|&h| !scene.is_layer_locked(h)),
);
}
if handles.is_empty() {
self.command_line
@@ -1797,7 +1787,10 @@ impl OpenCADStudio {
let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it
let _ = color; // color is captured inside mesh_fn
if let Some(mesh) = mesh_fn(name) {
self.tabs[i].scene.meshes.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i]
.scene
.meshes
.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
}
self.tabs[i].dirty = true;
self.command_line.push_output("Solid created.");
@@ -1838,15 +1831,18 @@ impl OpenCADStudio {
verts_low,
normals,
indices,
} => Some((crate::scene::model::mesh_model::MeshModel {
name: String::new(),
verts,
verts_low,
normals,
indices,
color,
selected: false,
}, solid)),
} => Some((
crate::scene::model::mesh_model::MeshModel {
name: String::new(),
verts,
verts_low,
normals,
indices,
color,
selected: false,
},
solid,
)),
_ => None,
}
}
@@ -1858,7 +1854,10 @@ impl OpenCADStudio {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i]
.scene
.meshes
.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
// Keep the truck B-rep so the save path can export exact
// ACIS geometry (else the solid is dropped by other CAD apps).
self.tabs[i].scene.solid_models.insert(new_handle, solid);
@@ -1937,7 +1936,10 @@ impl OpenCADStudio {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i]
.scene
.meshes
.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg));
@@ -2102,7 +2104,10 @@ impl OpenCADStudio {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i]
.scene
.meshes
.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output("SWEEP: solid created.");
} else {
@@ -2180,14 +2185,18 @@ impl OpenCADStudio {
}),
_ => None,
}
})();
})(
);
if let Some(mut mesh) = result {
self.push_undo_snapshot(i, "LOFT");
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i]
.scene
.meshes
.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"LOFT: solid created from {} profiles.",
@@ -2275,9 +2284,11 @@ impl OpenCADStudio {
return self.begin_text_edit(handle);
}
CmdResult::SuspendForTextEdit { handle } => {
let is_editable = crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document);
let is_editable =
crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document);
if !is_editable {
self.command_line.push_error("TEXTEDIT: selected entity is not text.");
self.command_line
.push_error("TEXTEDIT: selected entity is not text.");
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
@@ -2304,7 +2315,8 @@ impl OpenCADStudio {
CmdResult::SetTexteditMode(val) => {
self.texteditmode = val;
let display_val = if val { 1 } else { 0 };
self.command_line.push_output(&format!("TEXTEDITMODE set to {display_val}"));
self.command_line
.push_output(&format!("TEXTEDITMODE set to {display_val}"));
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
@@ -2430,7 +2442,13 @@ impl OpenCADStudio {
}
let blocks = self.clipboard_deps.blocks.clone();
for def in blocks {
if self.tabs[i].scene.document.block_records.get(&def.name).is_some() {
if self.tabs[i]
.scene
.document
.block_records
.get(&def.name)
.is_some()
{
continue;
}
self.tabs[i]
+2863 -2590
View File
File diff suppressed because it is too large. Load diff
+487 -204
View File
File diff suppressed because it is too large. Load diff
+234 -79
View File
@@ -4,7 +4,7 @@
//! to 2-D pixel coordinates, then compared against the cursor or selection box.
//! This matches the visual result the user sees.
use rustc_hash::FxHashMap as HashMap;
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use acadrust::Handle;
use glam::Mat4;
@@ -13,6 +13,7 @@ use iced::{Point, Rectangle};
use crate::scene::model::hatch_model::HatchModel;
use crate::scene::model::mesh_model::MeshModel;
use crate::scene::model::wire_model::WireModel;
use crate::scene::pick::interaction_index::WireSource;
/// Pixel radius used for single-click wire detection.
pub const CLICK_THRESHOLD_PX: f32 = 8.0;
@@ -167,9 +168,9 @@ fn text_quad_hit_area(
/// within that wire's [`pick_tolerance_px`] of `cursor`.
///
/// Returns `None` when no wire is close enough.
pub fn click_hit<'a>(
pub fn click_hit<'a, W: WireSource + ?Sized>(
cursor: Point,
wires: &'a [WireModel],
wires: &'a W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
@@ -193,32 +194,69 @@ pub fn click_hit<'a>(
// points (the dominant per-move cost on 100 k-wire drawings).
let z_flat = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9;
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires {
let tol = pick_tolerance_px(wire, lw_display);
// Cheap AABB pre-reject (flat view only; never for the unbounded
// sentinel used by previews / greeked text).
if z_flat
&& wire.aabb != WireModel::UNBOUNDED_AABB
&& aabb_rejects(wire.aabb, cursor, tol, view_rot, eye, bounds)
{
continue;
}
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() {
prev = None;
if let Some(segments) = wires.segments() {
// The interaction index already narrowed long/batched wires to segments
// touching the cursor aperture. Hover and click therefore project only
// those local edges, not every point of every candidate wire.
for segment in segments {
let Some(wire) = wires.source_wire(segment.wire) else {
continue;
};
let start = segment.start as usize;
if start + 1 >= wire.points.len() {
continue;
}
let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < tol && d < best_dist {
best_dist = d;
best = Some(&wire.name);
let p0 = world_to_screen(
wp64(wire.points[start], &wire.points_low, start),
view_rot,
eye,
bounds,
);
let p1 = world_to_screen(
wp64(wire.points[start + 1], &wire.points_low, start + 1),
view_rot,
eye,
bounds,
);
let d = dist_point_to_segment(cursor, p0, p1);
if d < pick_tolerance_px(wire, lw_display) && d < best_dist {
best_dist = d;
best = Some(&wire.name);
}
}
} else {
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires.iter() {
let tol = pick_tolerance_px(wire, lw_display);
// Cheap AABB pre-reject (flat view only; never for the unbounded
// sentinel used by previews / greeked text).
if z_flat
&& wire.aabb != WireModel::UNBOUNDED_AABB
&& aabb_rejects(wire.aabb, cursor, tol, view_rot, eye, bounds)
{
continue;
}
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() {
prev = None;
continue;
}
let cur = world_to_screen(
wp64([px, py, pz], &wire.points_low, i),
view_rot,
eye,
bounds,
);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < tol && d < best_dist {
best_dist = d;
best = Some(&wire.name);
}
}
prev = Some(cur);
}
prev = Some(cur);
}
}
@@ -232,7 +270,7 @@ pub fn click_hit<'a>(
// 3D solid is. Same projected-triangle containment as `mesh_click_hit`,
// front-most wins.
let mut best_fill: Option<(f32, &str)> = None;
for wire in wires {
for wire in wires.iter() {
if wire.fill_tris.is_empty() {
continue;
}
@@ -263,7 +301,7 @@ pub fn click_hit<'a>(
// surface — where the two overlap, the nearer thing to the eye is decided
// by depth, but a wire that has a real fill should win on it first.
let mut best_wall: Option<(f32, &str)> = None;
for wire in wires {
for wire in wires.iter() {
if wire.pick_tris.is_empty() {
continue;
}
@@ -301,7 +339,7 @@ pub fn click_hit<'a>(
// priority — real edges and fills above always win.
let mut best_area = f32::MAX;
let mut best_box: Option<&str> = None;
for wire in wires {
for wire in wires.iter() {
if wire.text_verts.is_empty() {
continue;
}
@@ -318,9 +356,9 @@ pub fn click_hit<'a>(
/// Like `click_hit` but returns every wire within the click threshold,
/// nearest first. Used by selection cycling to step through overlapping
/// objects under the cursor.
pub fn click_hits_all<'a>(
pub fn click_hits_all<'a, W: WireSource + ?Sized>(
cursor: Point,
wires: &'a [WireModel],
wires: &'a W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
@@ -330,28 +368,70 @@ pub fn click_hits_all<'a>(
return Vec::new();
}
let mut hits: Vec<(f32, &str)> = Vec::new();
for wire in wires {
let tol = pick_tolerance_px(wire, lw_display);
let mut prev: Option<Point> = None;
let mut best_for_wire = tol;
let mut hit = false;
for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() {
prev = None;
if let Some(segments) = wires.segments() {
let mut best_by_wire: HashMap<u32, f32> = HashMap::default();
for segment in segments {
let Some(wire) = wires.source_wire(segment.wire) else {
continue;
};
let start = segment.start as usize;
if start + 1 >= wire.points.len() {
continue;
}
let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_for_wire {
best_for_wire = d;
hit = true;
}
let p0 = world_to_screen(
wp64(wire.points[start], &wire.points_low, start),
view_rot,
eye,
bounds,
);
let p1 = world_to_screen(
wp64(wire.points[start + 1], &wire.points_low, start + 1),
view_rot,
eye,
bounds,
);
let d = dist_point_to_segment(cursor, p0, p1);
if d < pick_tolerance_px(wire, lw_display) {
best_by_wire
.entry(segment.wire)
.and_modify(|best| *best = best.min(d))
.or_insert(d);
}
prev = Some(cur);
}
if hit {
hits.push((best_for_wire, &wire.name));
hits.extend(best_by_wire.into_iter().filter_map(|(wire_idx, distance)| {
wires
.source_wire(wire_idx)
.map(|wire| (distance, wire.name.as_str()))
}));
} else {
for wire in wires.iter() {
let tol = pick_tolerance_px(wire, lw_display);
let mut prev: Option<Point> = None;
let mut best_for_wire = tol;
let mut hit = false;
for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() {
prev = None;
continue;
}
let cur = world_to_screen(
wp64([px, py, pz], &wire.points_low, i),
view_rot,
eye,
bounds,
);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_for_wire {
best_for_wire = d;
hit = true;
}
}
prev = Some(cur);
}
if hit {
hits.push((best_for_wire, &wire.name));
}
}
}
// Thickness walls join the cycle so an extruded entity picked on its wall
@@ -360,7 +440,7 @@ pub fn click_hits_all<'a>(
//
// A wall's own edges live on the same wire, so skip any wire the loop above
// already caught: cycling must not offer one entity twice.
for wire in wires {
for wire in wires.iter() {
if wire.pick_tris.is_empty() || hits.iter().any(|&(_, n)| n == wire.name) {
continue;
}
@@ -380,7 +460,7 @@ pub fn click_hits_all<'a>(
// SDF text: use the same exact glyph-quad test as `click_hit`; a batched
// text wire's union AABB may cover empty space between distant runs (#438).
// Ranked after real geometry (distance = the click threshold).
for wire in wires {
for wire in wires.iter() {
if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) {
continue;
}
@@ -418,12 +498,21 @@ pub fn aabb_under_cursor(
if !min_x.is_finite() || !min_z.is_finite() {
return true; // degenerate bound — don't cull
}
let (mut sx0, mut sy0, mut sx1, mut sy1) =
(f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
let (mut sx0, mut sy0, mut sx1, mut sy1) = (
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
);
for &z in &[min_z, max_z] {
for &(x, y) in &[(min_x, min_y), (max_x, min_y), (min_x, max_y), (max_x, max_y)] {
let rel = glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z)
.as_vec3();
for &(x, y) in &[
(min_x, min_y),
(max_x, min_y),
(min_x, max_y),
(max_x, max_y),
] {
let rel =
glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z).as_vec3();
let clip = view_proj * rel.extend(1.0);
if clip.w <= 1e-6 {
return true; // corner at/behind the camera — can't bound; keep it
@@ -493,7 +582,12 @@ fn mesh_vert(hi: [f32; 3], low: &[[f32; 3]], i: usize) -> glam::DVec3 {
}
/// Project a mesh's vertices to screen space.
fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec<Point> {
fn project_mesh_verts(
mesh: &MeshModel,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Vec<Point> {
mesh.verts
.iter()
.enumerate()
@@ -607,11 +701,11 @@ pub fn mesh_poly_hit<'a>(
/// ANY projected point inside the box, OR any wire segment crosses the box
/// boundary (so large entities like viewport frames are caught even when
/// no corner falls inside the selection rectangle).
pub fn box_hit<'a>(
pub fn box_hit<'a, W: WireSource + ?Sized>(
corner_a: Point,
corner_b: Point,
crossing: bool,
wires: &'a [WireModel],
wires: &'a W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
@@ -719,10 +813,10 @@ pub fn box_hit<'a>(
/// - **Window mode** (`crossing = false`): ALL projected points inside polygon.
/// - **Crossing mode** (`crossing = true`): ANY point inside OR any wire
/// segment crosses a polygon edge.
pub fn poly_hit<'a>(
pub fn poly_hit<'a, W: WireSource + ?Sized>(
poly: &[Point],
crossing: bool,
wires: &'a [WireModel],
wires: &'a W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
@@ -813,7 +907,12 @@ pub fn poly_hit<'a>(
// ── Helpers ───────────────────────────────────────────────────────────────
fn world_to_screen(world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
fn world_to_screen(
world: glam::DVec3,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Point {
let ndc = view_rot.project_point3((world - eye).as_vec3());
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
@@ -845,9 +944,7 @@ fn wp64(hi: [f32; 3], low: &[[f32; 3]], i: usize) -> glam::DVec3 {
fn point_in_polygon(p: Point, poly: &[Point]) -> bool {
// Ray-cast crossing test for a single edge a→b.
fn cross(p: Point, a: Point, b: Point, inside: &mut bool) {
if (a.y > p.y) != (b.y > p.y)
&& p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x
{
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
*inside = !*inside;
}
}
@@ -861,13 +958,14 @@ fn point_in_polygon(p: Point, poly: &[Point]) -> bool {
// sub-path that is a real ring (≥3 verts); closing a 2-point explicit edge
// would add a degenerate back-edge that cancels its own crossing.
let mut count = 0usize;
let close = |prev: Option<Point>, path_start: Option<Point>, count: usize, inside: &mut bool| {
if count >= 3 {
if let (Some(pv), Some(sv)) = (prev, path_start) {
cross(p, pv, sv, inside);
let close =
|prev: Option<Point>, path_start: Option<Point>, count: usize, inside: &mut bool| {
if count >= 3 {
if let (Some(pv), Some(sv)) = (prev, path_start) {
cross(p, pv, sv, inside);
}
}
}
};
};
for &pt in poly {
if !pt.x.is_finite() || !pt.y.is_finite() {
close(prev, path_start, count, &mut inside);
@@ -928,8 +1026,12 @@ pub fn click_hit_hatch(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
) -> Option<Handle> {
for (&handle, hatch) in hatches {
if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) {
continue;
}
if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) {
return Some(handle);
}
@@ -948,8 +1050,12 @@ pub fn click_hit_insert_hatch(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
) -> Option<Handle> {
for (handle, hatch) in insert_hatches {
if candidate_handles.is_some_and(|handles| !handles.contains(handle)) {
continue;
}
if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) {
return Some(*handle);
}
@@ -978,7 +1084,12 @@ fn hatch_contains_screen_point(
.iter()
.map(|&[x, y]| {
if x.is_finite() && y.is_finite() {
world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds)
world_to_screen(
glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0),
view_rot,
eye,
bounds,
)
} else {
// Preserve path separators for the NaN-aware
// point_in_polygon ray-cast.
@@ -998,6 +1109,7 @@ pub fn box_hit_hatch(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
) -> Vec<Handle> {
let min_x = corner_a.x.min(corner_b.x);
let max_x = corner_a.x.max(corner_b.x);
@@ -1013,6 +1125,9 @@ pub fn box_hit_hatch(
hatches
.iter()
.filter_map(|(&handle, hatch)| {
if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) {
return None;
}
if hatch.boundary.is_empty() {
return None;
}
@@ -1020,7 +1135,14 @@ pub fn box_hit_hatch(
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds))
.map(|&[x, y]| {
world_to_screen(
glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0),
view_rot,
eye,
bounds,
)
})
.collect();
let hit = if crossing {
screen.iter().any(|&sp| inside(sp))
@@ -1044,6 +1166,7 @@ pub fn poly_hit_hatch(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
) -> Vec<Handle> {
if poly.len() < 3 {
return vec![];
@@ -1052,6 +1175,9 @@ pub fn poly_hit_hatch(
hatches
.iter()
.filter_map(|(&handle, hatch)| {
if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) {
return None;
}
if hatch.boundary.is_empty() {
return None;
}
@@ -1059,7 +1185,14 @@ pub fn poly_hit_hatch(
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds))
.map(|&[x, y]| {
world_to_screen(
glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0),
view_rot,
eye,
bounds,
)
})
.collect();
let hit = if crossing {
screen.iter().any(|&sp| point_in_polygon(sp, poly))
@@ -1113,16 +1246,38 @@ mod aabb_reject_tests {
#[test]
fn aabb_reject_keeps_near_wire_drops_far() {
let vp = Mat4::IDENTITY;
let bounds = Rectangle { x: 0.0, y: 0.0, width: 200.0, height: 200.0 };
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
};
let cursor = Point::new(100.0, 100.0); // world origin
let near = wire("5", vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]], [-0.02, 0.0, 0.02, 0.0]);
let far = wire("9", vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], [0.9, 0.9, 0.95, 0.9]);
let near = wire(
"5",
vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]],
[-0.02, 0.0, 0.02, 0.0],
);
let far = wire(
"9",
vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]],
[0.9, 0.9, 0.95, 0.9],
);
let eye = glam::DVec3::ZERO;
assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true), Some("5"));
assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true), None);
assert_eq!(
click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true),
Some("5")
);
assert_eq!(
click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true),
None
);
// The far wire must be rejected without hiding the near one.
assert_eq!(click_hit(cursor, &[far, near], vp, eye, bounds, true), Some("5"));
assert_eq!(
click_hit(cursor, &[far, near], vp, eye, bounds, true),
Some("5")
);
}
}
+433
View File
@@ -0,0 +1,433 @@
//! Shared spatial broad phase for cursor and area interactions.
//!
//! Exact snap and selection rules remain in their consumers. This index only
//! narrows a large, camera-independent Model wire set to wire and segment
//! candidates whose world-XY bounds overlap the interaction aperture.
use crate::scene::model::wire_model::WireModel;
use std::sync::Arc;
const TARGET_ENTRIES_PER_CELL: usize = 8;
const MAX_SPAN_CELLS: u64 = 64;
const MAX_AXIS_CELLS: u32 = 8_192;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct SegmentRef {
pub wire: u32,
pub start: u32,
}
#[derive(Clone, Copy)]
struct Entry<T> {
aabb: [f64; 4],
value: T,
}
struct SpatialGrid<T> {
min: [f64; 2],
cell: f64,
cols: u32,
rows: u32,
entries: Vec<Entry<T>>,
cells: Vec<Vec<u32>>,
oversized: Vec<u32>,
}
impl<T: Copy + Ord> SpatialGrid<T> {
fn build(entries: Vec<Entry<T>>) -> Self {
if entries.is_empty() {
return Self {
min: [0.0; 2],
cell: 1.0,
cols: 1,
rows: 1,
entries,
cells: vec![Vec::new()],
oversized: Vec::new(),
};
}
let mut min = [f64::INFINITY; 2];
let mut max = [f64::NEG_INFINITY; 2];
for entry in &entries {
min[0] = min[0].min(entry.aabb[0]);
min[1] = min[1].min(entry.aabb[1]);
max[0] = max[0].max(entry.aabb[2]);
max[1] = max[1].max(entry.aabb[3]);
}
let ext_x = (max[0] - min[0]).max(1e-9);
let ext_y = (max[1] - min[1]).max(1e-9);
let target_cells = ((entries.len() / TARGET_ENTRIES_PER_CELL).max(1)) as f64;
let cell = ((ext_x * ext_y) / target_cells)
.sqrt()
.max(ext_x / (MAX_AXIS_CELLS - 1) as f64)
.max(ext_y / (MAX_AXIS_CELLS - 1) as f64)
.max(1e-9);
let cols = (((ext_x / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32;
let rows = (((ext_y / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32;
let mut cells = vec![Vec::new(); cols as usize * rows as usize];
let mut oversized = Vec::new();
let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32;
let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32;
for (idx, entry) in entries.iter().enumerate() {
let c0 = col_of(entry.aabb[0]);
let c1 = col_of(entry.aabb[2]);
let r0 = row_of(entry.aabb[1]);
let r1 = row_of(entry.aabb[3]);
let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64;
if span > MAX_SPAN_CELLS {
oversized.push(idx as u32);
continue;
}
for row in r0..=r1 {
let base = row as usize * cols as usize;
for col in c0..=c1 {
cells[base + col as usize].push(idx as u32);
}
}
}
Self {
min,
cell,
cols,
rows,
entries,
cells,
oversized,
}
}
fn query(&self, query: [f64; 4]) -> Vec<T> {
let mut entry_indices = self.oversized.clone();
let grid_max_x = self.min[0] + self.cols as f64 * self.cell;
let grid_max_y = self.min[1] + self.rows as f64 * self.cell;
if query[2] >= self.min[0]
&& query[3] >= self.min[1]
&& query[0] <= grid_max_x
&& query[1] <= grid_max_y
{
let col = |x: f64| {
(((x - self.min[0]) / self.cell).floor()).clamp(0.0, (self.cols - 1) as f64) as u32
};
let row = |y: f64| {
(((y - self.min[1]) / self.cell).floor()).clamp(0.0, (self.rows - 1) as f64) as u32
};
for r in row(query[1])..=row(query[3]) {
let base = r as usize * self.cols as usize;
for c in col(query[0])..=col(query[2]) {
entry_indices.extend_from_slice(&self.cells[base + c as usize]);
}
}
}
entry_indices.sort_unstable();
entry_indices.dedup();
let mut out: Vec<T> = entry_indices
.into_iter()
.filter_map(|idx| {
let entry = self.entries.get(idx as usize)?;
aabb_overlaps(entry.aabb, query).then_some(entry.value)
})
.collect();
out.sort_unstable();
out.dedup();
out
}
}
pub struct InteractionIndex {
wires: SpatialGrid<u32>,
segments: SpatialGrid<SegmentRef>,
unbounded_wires: Vec<u32>,
}
pub struct InteractionHandleIndex {
handles: SpatialGrid<u64>,
}
impl InteractionHandleIndex {
pub fn build(entries: impl IntoIterator<Item = (u64, [f64; 4])>) -> Self {
Self {
handles: SpatialGrid::build(
entries
.into_iter()
.filter(|(_, aabb)| {
aabb.iter().all(|value| value.is_finite())
&& aabb[0] <= aabb[2]
&& aabb[1] <= aabb[3]
})
.map(|(value, aabb)| Entry { aabb, value })
.collect(),
),
}
}
pub fn query(&self, aabb: [f64; 4]) -> Vec<u64> {
self.handles.query(aabb)
}
}
impl InteractionIndex {
pub fn build(wires: &[WireModel]) -> Self {
let mut wire_entries = Vec::with_capacity(wires.len());
let mut segment_entries = Vec::new();
let mut unbounded_wires = Vec::new();
for (wire_idx, wire) in wires.iter().enumerate() {
if let Some(aabb) = finite_wire_aabb(wire) {
wire_entries.push(Entry {
aabb,
value: wire_idx as u32,
});
} else {
unbounded_wires.push(wire_idx as u32);
}
for start in 0..wire.points.len().saturating_sub(1) {
let a = wire_point(wire, start);
let b = wire_point(wire, start + 1);
if !a[0].is_finite() || !a[1].is_finite() || !b[0].is_finite() || !b[1].is_finite()
{
continue;
}
segment_entries.push(Entry {
aabb: [
a[0].min(b[0]),
a[1].min(b[1]),
a[0].max(b[0]),
a[1].max(b[1]),
],
value: SegmentRef {
wire: wire_idx as u32,
start: start as u32,
},
});
}
}
Self {
wires: SpatialGrid::build(wire_entries),
segments: SpatialGrid::build(segment_entries),
unbounded_wires,
}
}
pub fn query(&self, wires: Arc<Vec<WireModel>>, aabb: [f64; 4]) -> InteractionCandidates {
let mut wire_indices = self.wires.query(aabb);
wire_indices.extend_from_slice(&self.unbounded_wires);
wire_indices.sort_unstable();
wire_indices.dedup();
InteractionCandidates {
wires,
wire_indices: Some(wire_indices),
segments: Some(self.segments.query(aabb)),
query_aabb: Some(aabb),
}
}
}
pub struct InteractionCandidates {
wires: Arc<Vec<WireModel>>,
wire_indices: Option<Vec<u32>>,
segments: Option<Vec<SegmentRef>>,
query_aabb: Option<[f64; 4]>,
}
pub trait WireSource {
fn iter(&self) -> WireIter<'_>;
fn len(&self) -> usize;
fn get(&self, index: usize) -> Option<&WireModel>;
fn segments(&self) -> Option<&[SegmentRef]> {
None
}
fn source_wire(&self, index: u32) -> Option<&WireModel>;
}
impl InteractionCandidates {
pub fn all(wires: Arc<Vec<WireModel>>) -> Self {
Self {
wires,
wire_indices: None,
segments: None,
query_aabb: None,
}
}
pub fn iter(&self) -> WireIter<'_> {
match &self.wire_indices {
Some(indices) => WireIter::Indexed {
wires: &self.wires,
indices: indices.iter(),
},
None => WireIter::All(self.wires.as_slice().iter()),
}
}
pub fn len(&self) -> usize {
self.wire_indices
.as_ref()
.map_or(self.wires.len(), Vec::len)
}
pub fn get(&self, index: usize) -> Option<&WireModel> {
match &self.wire_indices {
Some(indices) => indices
.get(index)
.and_then(|&wire| self.wires.get(wire as usize)),
None => self.wires.get(index),
}
}
pub fn segments(&self) -> Option<&[SegmentRef]> {
self.segments.as_deref()
}
pub fn source_wire(&self, index: u32) -> Option<&WireModel> {
self.wires.get(index as usize)
}
pub fn query_aabb(&self) -> Option<[f64; 4]> {
self.query_aabb
}
}
impl WireSource for InteractionCandidates {
fn iter(&self) -> WireIter<'_> {
self.iter()
}
fn len(&self) -> usize {
self.len()
}
fn get(&self, index: usize) -> Option<&WireModel> {
self.get(index)
}
fn segments(&self) -> Option<&[SegmentRef]> {
self.segments()
}
fn source_wire(&self, index: u32) -> Option<&WireModel> {
self.source_wire(index)
}
}
impl WireSource for [WireModel] {
fn iter(&self) -> WireIter<'_> {
WireIter::All(<[WireModel]>::iter(self))
}
fn len(&self) -> usize {
<[WireModel]>::len(self)
}
fn get(&self, index: usize) -> Option<&WireModel> {
<[WireModel]>::get(self, index)
}
fn source_wire(&self, index: u32) -> Option<&WireModel> {
self.get(index as usize)
}
}
impl WireSource for Vec<WireModel> {
fn iter(&self) -> WireIter<'_> {
WireIter::All(self.as_slice().iter())
}
fn len(&self) -> usize {
Vec::len(self)
}
fn get(&self, index: usize) -> Option<&WireModel> {
self.as_slice().get(index)
}
fn source_wire(&self, index: u32) -> Option<&WireModel> {
self.get(index as usize)
}
}
impl<const N: usize> WireSource for [WireModel; N] {
fn iter(&self) -> WireIter<'_> {
WireIter::All(self.as_slice().iter())
}
fn len(&self) -> usize {
N
}
fn get(&self, index: usize) -> Option<&WireModel> {
self.as_slice().get(index)
}
fn source_wire(&self, index: u32) -> Option<&WireModel> {
self.get(index as usize)
}
}
#[derive(Clone)]
pub enum WireIter<'a> {
All(std::slice::Iter<'a, WireModel>),
Indexed {
wires: &'a [WireModel],
indices: std::slice::Iter<'a, u32>,
},
}
impl<'a> Iterator for WireIter<'a> {
type Item = &'a WireModel;
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::All(iter) => iter.next(),
Self::Indexed { wires, indices } => {
indices.next().and_then(|&idx| wires.get(idx as usize))
}
}
}
}
fn finite_wire_aabb(wire: &WireModel) -> Option<[f64; 4]> {
let [min_x, min_y, max_x, max_y] = wire.aabb;
(min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()).then(|| {
let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs()));
let pad = (mag * f32::EPSILON * 2.0) as f64;
let mut aabb = [
min_x as f64 - pad,
min_y as f64 - pad,
max_x as f64 + pad,
max_y as f64 + pad,
];
// Some valid snap points are outside visible curve bounds: notably the
// center of a short arc. Keep those discoverable without widening the
// segment query used by Intersection/Nearest.
for (point, _) in &wire.snap_pts {
if point.x.is_finite() && point.y.is_finite() {
aabb[0] = aabb[0].min(point.x);
aabb[1] = aabb[1].min(point.y);
aabb[2] = aabb[2].max(point.x);
aabb[3] = aabb[3].max(point.y);
}
}
aabb
})
}
fn wire_point(wire: &WireModel, index: usize) -> [f64; 3] {
let high = wire.points[index];
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
[
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
]
}
fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool {
a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3]
}
+3 -3
View File
@@ -1,6 +1,6 @@
pub mod hit_test;
pub mod selection_state;
pub mod grip;
pub mod hit_test;
pub mod interaction_index;
pub mod quadtree;
pub mod wire_grid;
pub mod selection_state;
pub mod xclip;
-178
View File
@@ -1,178 +0,0 @@
//! Uniform spatial grid over a hit-test wire set's world-XY AABBs.
//!
//! Snap and hover hit-testing scan the full model wire set on every cursor
//! move. On a heavily block-instanced drawing that set explodes to millions of
//! wires, so an O(N) per-move scan — even with a cheap AABB reject — stalls the
//! event loop. This grid is built once per geometry epoch (cached alongside the
//! wire set) and answers "which wires are near this cursor point" in O(cells in
//! range), turning the per-move cost from O(all wires) into O(local).
//!
//! Wires whose AABB is unbounded (previews / entities with no usable
//! bounding box) or spans a large fraction of the grid (drawing-length lines)
//! go into an always-checked `oversized` list rather than being smeared across
//! thousands of cells.
use crate::scene::model::wire_model::WireModel;
/// A wire spanning more than this many cells is stored once in `oversized`
/// instead of being inserted into every overlapped cell.
const MAX_SPAN_CELLS: u64 = 64;
pub struct WireGrid {
min: [f64; 2],
cell: f64,
cols: u32,
rows: u32,
cells: Vec<Vec<u32>>,
/// Indices always returned as candidates (unbounded / huge AABB).
oversized: Vec<u32>,
}
impl WireGrid {
fn finite_aabb(w: &WireModel) -> Option<[f64; 4]> {
let a = w.aabb;
(a[0].is_finite() && a[1].is_finite() && a[2].is_finite() && a[3].is_finite())
.then_some([a[0] as f64, a[1] as f64, a[2] as f64, a[3] as f64])
}
pub fn build(wires: &[WireModel]) -> Self {
// Overall finite extent.
let mut min = [f64::INFINITY; 2];
let mut max = [f64::NEG_INFINITY; 2];
for w in wires {
if let Some(a) = Self::finite_aabb(w) {
min[0] = min[0].min(a[0]);
min[1] = min[1].min(a[1]);
max[0] = max[0].max(a[2]);
max[1] = max[1].max(a[3]);
}
}
if !min[0].is_finite() {
// No finite wires — everything is oversized (always checked).
return Self {
min: [0.0, 0.0],
cell: 1.0,
cols: 1,
rows: 1,
cells: vec![Vec::new()],
oversized: (0..wires.len() as u32).collect(),
};
}
let ext_x = (max[0] - min[0]).max(1e-6);
let ext_y = (max[1] - min[1]).max(1e-6);
// Aim for ~8 wires/cell; a square-ish cell keeps queries tight.
let target_cells = ((wires.len() / 8).max(1)) as f64;
let cell = ((ext_x * ext_y) / target_cells).sqrt().max(1e-6);
let cols = (((ext_x / cell).ceil() as i64) + 1).clamp(1, 8192) as u32;
let rows = (((ext_y / cell).ceil() as i64) + 1).clamp(1, 8192) as u32;
let mut cells = vec![Vec::<u32>::new(); (cols as usize) * (rows as usize)];
let mut oversized = Vec::new();
let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32;
let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32;
for (idx, w) in wires.iter().enumerate() {
let Some(a) = Self::finite_aabb(w) else {
oversized.push(idx as u32);
continue;
};
let c0 = col_of(a[0]);
let c1 = col_of(a[2]);
let r0 = row_of(a[1]);
let r1 = row_of(a[3]);
let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64;
if span > MAX_SPAN_CELLS {
oversized.push(idx as u32);
continue;
}
for r in r0..=r1 {
let base = (r * cols) as usize;
for c in c0..=c1 {
cells[base + c as usize].push(idx as u32);
}
}
}
Self {
min,
cell,
cols,
rows,
cells,
oversized,
}
}
/// Candidate wire indices whose cell overlaps the `radius`-padded box around
/// `(x, y)`. Always includes the oversized list. May contain a wire more
/// than once is avoided by dedup.
pub fn query(&self, x: f64, y: f64, radius: f64) -> Vec<u32> {
let r = radius.max(0.0);
let mut out = self.oversized.clone();
// Query box fully outside the grid → only oversized candidates.
if x + r < self.min[0]
|| y + r < self.min[1]
|| x - r > self.min[0] + self.cols as f64 * self.cell
|| y - r > self.min[1] + self.rows as f64 * self.cell
{
return out;
}
let clampc = |v: f64| v.clamp(0.0, (self.cols - 1) as f64) as u32;
let clampr = |v: f64| v.clamp(0.0, (self.rows - 1) as f64) as u32;
let c0 = clampc(((x - r) - self.min[0]) / self.cell);
let c1 = clampc(((x + r) - self.min[0]) / self.cell);
let r0 = clampr(((y - r) - self.min[1]) / self.cell);
let r1 = clampr(((y + r) - self.min[1]) / self.cell);
for row in r0..=r1 {
let base = (row * self.cols) as usize;
for col in c0..=c1 {
out.extend_from_slice(&self.cells[base + col as usize]);
}
}
out.sort_unstable();
out.dedup();
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(x: f32, y: f32) -> WireModel {
let mut w = WireModel::solid_f64("t".into(), vec![[x as f64, y as f64, 0.0]], [0.0; 4], false);
w.aabb = [x, y, x, y];
w
}
#[test]
fn query_is_local_and_hits_the_point() {
// 50×50 lattice of point-wires spaced 10 apart (2500 wires).
let mut wires = Vec::new();
for gx in 0..50 {
for gy in 0..50 {
wires.push(wire(gx as f32 * 10.0, gy as f32 * 10.0));
}
}
let n = wires.len();
let grid = WireGrid::build(&wires);
// Tight query around one lattice point.
let hits = grid.query(250.0, 250.0, 5.0);
assert!(hits.len() < n / 10, "query too broad: {} of {n}", hits.len());
let idx = (25u32 * 50) + 25; // gx=25, gy=25
assert!(hits.contains(&idx), "the on-point wire must be a candidate");
// A far point must not drag in the whole lattice.
let far = grid.query(-9999.0, -9999.0, 5.0);
assert!(far.len() < n / 10, "far query too broad: {}", far.len());
}
#[test]
fn unbounded_wire_is_always_a_candidate() {
let mut wires = vec![wire(0.0, 0.0), wire(1_000_000.0, 1_000_000.0)];
wires[1].aabb = WireModel::UNBOUNDED_AABB;
let grid = WireGrid::build(&wires);
let hits = grid.query(0.0, 0.0, 1.0);
assert!(hits.contains(&1), "unbounded wire must always be returned");
}
}
+517 -320
View File
File diff suppressed because it is too large. Load diff