fix(hit_test): correct hatch selection — NaN-aware ray-cast + block-insert resolve

Two related click bugs:

1. `point_in_polygon` treated NaN points (the path separators used by
   multi-path hatches with islands / holes) as regular vertices,
   producing spurious closing edges between sub-paths. The cursor
   inside a real hatch failed the test → the click iterator moved on
   to the NEXT hatch in HashMap order and silently returned the wrong
   one. Rewrite the ray-cast to reset the previous-vertex / path-start
   state at every NaN, so each sub-path closes against its own first
   vertex and contributes its parity flip correctly.

2. Clicking on a hatch *inside* a block was either missing entirely
   (after the visible_hatches_for_click filter) or returning the
   block-defn source hatch handle (misleading — that handle isn't the
   thing the user sees). AutoCAD selects the parent Insert when a
   sub-entity of a block is clicked. Add `Scene::insert_hatches_for_click`
   which walks the same explode path `synced_hatch_models` uses and
   tags every exploded sub-hatch with its parent Insert handle, plus
   `hit_test::click_hit_insert_hatch` which iterates that list. The
   click resolver now tries wire → MSPACE hatch → insert-internal hatch
   in order, so clicking on a block-internal hatch selects the Insert.

Box / lasso select paths are unchanged — wire hit-test already picks
up Inserts, and dragging a box over block-internal hatches is rare
enough not to need its own resolution path yet.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-16 13:02:57 +03:00
1 parent 0f252e8b53
commit c815be9c6f
3 files changed
+174 -20

No files matched your search

+14 -4
View File
@@ -1998,7 +1998,7 @@ impl H7CAD {
a,
p,
crossing,
&self.tabs[i].scene.hatches,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
));
@@ -2031,7 +2031,7 @@ impl H7CAD {
handles.extend(scene::hit_test::poly_hit_hatch(
&poly_pts,
crossing,
&self.tabs[i].scene.hatches,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
));
@@ -2058,7 +2058,17 @@ impl H7CAD {
.or_else(|| {
scene::hit_test::click_hit_hatch(
p,
&self.tabs[i].scene.hatches,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
)
})
.or_else(|| {
// Block-internal hatch: resolve to the
// parent Insert (AutoCAD behaviour).
scene::hit_test::click_hit_insert_hatch(
p,
&self.tabs[i].scene.insert_hatches_for_click(),
vp_mat,
bounds,
)
@@ -2096,7 +2106,7 @@ impl H7CAD {
a,
p,
crossing,
&self.tabs[i].scene.hatches,
&self.tabs[i].scene.visible_hatches_for_click(),
vp_mat,
bounds,
));
+87 -16
View File
@@ -222,16 +222,51 @@ fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point {
}
/// Even-odd ray-casting test: is `p` inside the polygon?
///
/// Handles multi-path boundaries: NaN points (used as path separators by
/// hatches with islands / holes) reset the previous-vertex tracking so
/// that the ray-cast doesn't draw a spurious closing edge between the
/// end of one sub-path and the start of the next. Each sub-path with at
/// least 2 finite vertices contributes its segments to the parity flip.
fn point_in_polygon(p: Point, poly: &[Point]) -> bool {
let n = poly.len();
let mut inside = false;
let (mut xi, mut yi) = (poly[n - 1].x, poly[n - 1].y);
let mut prev: Option<Point> = None;
let mut path_start: Option<Point> = None;
for &pt in poly {
let (xj, yj) = (pt.x, pt.y);
if ((yi > p.y) != (yj > p.y)) && (p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi) {
if !pt.x.is_finite() || !pt.y.is_finite() {
// Close the just-finished sub-path before starting the next.
if let (Some(prev_v), Some(start_v)) = (prev, path_start) {
if ((prev_v.y > p.y) != (start_v.y > p.y))
&& (p.x
< (start_v.x - prev_v.x) * (p.y - prev_v.y) / (start_v.y - prev_v.y)
+ prev_v.x)
{
inside = !inside;
}
}
prev = None;
path_start = None;
continue;
}
if let Some(prev_v) = prev {
if ((prev_v.y > p.y) != (pt.y > p.y))
&& (p.x < (pt.x - prev_v.x) * (p.y - prev_v.y) / (pt.y - prev_v.y) + prev_v.x)
{
inside = !inside;
}
} else {
path_start = Some(pt);
}
prev = Some(pt);
}
// Close the final sub-path.
if let (Some(prev_v), Some(start_v)) = (prev, path_start) {
if ((prev_v.y > p.y) != (start_v.y > p.y))
&& (p.x
< (start_v.x - prev_v.x) * (p.y - prev_v.y) / (start_v.y - prev_v.y) + prev_v.x)
{
inside = !inside;
}
(xi, yi) = (xj, yj);
}
inside
}
@@ -277,23 +312,59 @@ pub fn click_hit_hatch(
bounds: Rectangle,
) -> Option<Handle> {
for (&handle, hatch) in hatches {
// boundary verts are stored as small f32 offsets from
// `world_origin` (f64). Reconstruct offset-rel WCS before
// projecting to screen.
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds))
.collect();
if screen.len() >= 3 && point_in_polygon(cursor, &screen) {
if hatch_contains_screen_point(hatch, cursor, view_proj, bounds) {
return Some(handle);
}
}
None
}
/// Same as `click_hit_hatch` but iterates `(Handle, HatchModel)` pairs
/// where the Handle is the parent Insert handle (block-internal
/// hatches). The first matching pair returns its Insert handle so
/// clicking a sub-hatch of a block selects the Insert, matching
/// AutoCAD's behaviour for block sub-entities.
pub fn click_hit_insert_hatch(
cursor: Point,
insert_hatches: &[(Handle, HatchModel)],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<Handle> {
for (handle, hatch) in insert_hatches {
if hatch_contains_screen_point(hatch, cursor, view_proj, bounds) {
return Some(*handle);
}
}
None
}
fn hatch_contains_screen_point(
hatch: &HatchModel,
cursor: Point,
view_proj: Mat4,
bounds: Rectangle,
) -> bool {
// boundary verts are stored as small f32 offsets from
// `world_origin` (f64). Reconstruct offset-rel WCS before
// projecting to screen.
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| {
if x.is_finite() && y.is_finite() {
world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds)
} else {
// Preserve path separators for the NaN-aware
// point_in_polygon ray-cast.
Point::new(f32::NAN, f32::NAN)
}
})
.collect();
screen.len() >= 3 && point_in_polygon(cursor, &screen)
}
/// Return Handles of hatches selected by a completed rectangular selection box.
pub fn box_hit_hatch(
corner_a: Point,
+73
View File
@@ -1103,6 +1103,79 @@ impl Scene {
arc
}
/// Hatches eligible for click / box / lasso hit-testing in the current
/// layout. Filters out block-internal source hatches (stored in
/// `self.hatches` at block-local coords for the block-defn position,
/// which doesn't project correctly through the offset-rel view_proj
/// and was causing the wrong hatch to be selected on click).
pub fn visible_hatches_for_click(&self) -> HashMap<Handle, HatchModel> {
let layout_block = self.current_layout_block_handle();
self.hatches
.iter()
.filter_map(|(&h, m)| {
let owner = self.document.get_entity(h)?.common().owner_handle;
if self.belongs_to_visible_block(h, owner, layout_block) {
Some((h, m.clone()))
} else {
None
}
})
.collect()
}
/// Per-Insert hatch models in the current layout, keyed by the Insert
/// handle so a click on a block-internal hatch can select the parent
/// Insert (AutoCAD behaviour: sub-entities of a block aren't directly
/// selectable; the click resolves to the Insert).
pub fn insert_hatches_for_click(&self) -> Vec<(Handle, HatchModel)> {
let layout_block = self.current_layout_block_handle();
let hatch_offset = if self.current_layout == "Model" {
self.world_offset
} else {
[0.0; 3]
};
let layer_hidden = |layer: &str| {
self.document
.layers
.get(layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
};
let mut out: Vec<(Handle, HatchModel)> = Vec::new();
for entity in self.document.entities() {
let EntityType::Insert(ins) = entity else {
continue;
};
if ins.common.invisible || layer_hidden(&ins.common.layer) {
continue;
}
if !self.belongs_to_visible_block(
ins.common.handle,
ins.common.owner_handle,
layout_block,
) {
continue;
}
for sub in ins
.explode_from_document(&self.document)
.into_iter()
.map(crate::modules::home::modify::explode::normalize_insert_entity)
{
let EntityType::Hatch(dxf) = sub else {
continue;
};
if dxf.common.invisible || layer_hidden(&dxf.common.layer) {
continue;
}
let color = self.render_style(&EntityType::Hatch(dxf.clone())).0;
if let Some(model) = Self::hatch_model_from_dxf(&dxf, color, hatch_offset) {
out.push((ins.common.handle, model));
}
}
}
out
}
/// Wires that should participate in hit-testing, snapping, and selection.
///
/// - Model layout: all entity wires (same as entity_wires).