forked from 1b-agent/opencadstudio-upstream
perf: unify spatial interaction queries
Dense drawings previously rescanned full geometry for snapping, tracking, and selection.\n\nRefs #433
This commit is contained in:
1 parent
ece5598372
commit
4db763ef27
8 files changed
+4606
-3425
No files matched your search
+69
-51
@@ -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
File diff suppressed because it is too large.
Load diff
+487
-204
File diff suppressed because it is too large.
Load diff
+234
-79
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user