//! Hit testing and window/crossing selection. use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Prim, Space, entity_bounds}; use cadcraft_geom::{Bounds2, Line, Vec2, point_in_polygon}; use crate::spatial::{self, SpatialIndex}; use crate::{Accept, Prompt}; /// Polylines approximating an entity for hit testing, tessellated at `tol`. pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec> { match &e.kind { EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_) | EntityKind::Dimension(_) | EntityKind::AttDef(_) | EntityKind::Table(_) | EntityKind::Tolerance(_) | EntityKind::MLeader(_) | EntityKind::Image(_) => { // Text-like objects are hit inside their box. let b = entity_bounds(d, e, 0); if b.is_empty() { return Vec::new(); } let c = b.corners(); let mut v = vec![vec![c[0], c[1], c[2], c[3], c[0]]]; if matches!(e.kind, EntityKind::Dimension(_) | EntityKind::Insert(_) | EntityKind::MLeader(_)) { let list = cadcraft_render::build_entities( d, std::iter::once(e), &cadcraft_render::Options { tolerance: tol, text: false, ..Default::default() }, ); v = list.prims.iter().map(|p| list.points(p).to_vec()).collect(); v.push(vec![c[0], c[1], c[2], c[3], c[0]]); } v } kind => { let mut out = Vec::new(); for p in kind.prims() { let mut pts = Vec::new(); match p { Prim::Seg(s) => s.tessellate(tol, &mut pts), Prim::Circle(c) => c.tessellate(tol, &mut pts), Prim::Ellipse(el) => el.tessellate(tol, &mut pts), Prim::Spline(s) => pts = s.tessellate(tol), Prim::Point(p) => pts = vec![p, p], Prim::Infinite { base, dir, ray } => { let far = 1e9; pts = vec![if ray { base } else { base - dir * far }, base + dir * far]; } Prim::Fill(f) => { pts = f.clone(); if let Some(first) = f.first() { pts.push(*first); } } } out.push(pts); } // A pattern hatch is also picked on its drawn pattern strokes, not only its boundary. if let EntityKind::Hatch(h) = kind && !h.solid && h.gradient.is_none() && !h.pattern.eq_ignore_ascii_case("SOLID") { let list = cadcraft_render::build_entities( d, std::iter::once(e), &cadcraft_render::Options { tolerance: tol, text: false, fill: false, ..Default::default() }, ); out.extend(list.prims.iter().map(|p| list.points(p).to_vec())); } out } } } fn dist_to_polyline(pts: &[Vec2], p: Vec2) -> f64 { if pts.len() == 1 { return pts.first().map(|q| q.dist(p)).unwrap_or(f64::INFINITY); } pts.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).dist(p))).fold(f64::INFINITY, f64::min) } /// Distance from `p` to the entity (0 inside text boxes and solid fills). pub fn entity_distance(d: &Drawing, e: &Entity, p: Vec2, tol: f64) -> f64 { let polys = hit_polylines(d, e, tol); let mut best = polys.iter().map(|pl| dist_to_polyline(pl, p)).fold(f64::INFINITY, f64::min); let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Table(_)) || matches!(e.kind, EntityKind::Tolerance(_)); if (filled && entity_bounds(d, e, 0).contains(p)) || in_text(d, e, p) { best = 0.0; } // A solid hatch fills its loops with odd parity under its island style, as it is drawn, not // its bounding rectangle. if let EntityKind::Hatch(h) = &e.kind && h.solid && cadcraft_render::hatch_fill_loops(h, tol).iter().filter(|l| point_in_polygon(l, p)).count() % 2 == 1 { best = 0.0; } // A SOLID fills its polygon, not its bounding rectangle (half of which is empty for a triangle). if matches!(e.kind, EntityKind::Solid(_)) && e.kind.prims().iter().any(|prim| matches!(prim, Prim::Fill(poly) if point_in_polygon(poly, p))) { best = 0.0; } best } /// Whether `p` is on the text of a dimension or multileader: inside the box around its words, /// turned with the text, as plain text is hit anywhere inside its box. Their other geometry is /// hit along its lines, so without this the cursor found the object only between the letters' /// edges and the lines. fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool { match &e.kind { // A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is // the text there, not the text the dimension's own geometry would place. EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) { Some(blk) => blk.entities.iter().any(|be| match &be.kind { EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p), EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p), _ => false, }), None => { let g = cadcraft_render::dimension_in(d, dm); in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p) } }, EntityKind::MLeader(m) => { m.text.as_ref().is_some_and(|t| in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p)) } _ => false, } } /// Whether `p` is inside the box around `pts` turned by `angle` (the box of text set at `angle`). fn in_box(pts: impl Iterator, angle: f64, p: Vec2) -> bool { let turn = |q: Vec2| q.rotate(-angle); let b = Bounds2::from_points(pts.filter(|q| q.is_finite()).map(turn)); !b.is_empty() && b.contains(turn(p)) } fn selectable(d: &Drawing, e: &Entity) -> bool { d.is_visible(e) } /// Entities to test, in the order to test them, each with its bounds when already known. pub(crate) type Cands<'a> = Box)> + 'a>; pub(crate) fn bounds_of(d: &Drawing, e: &Entity, known: Option) -> Bounds2 { known.unwrap_or_else(|| entity_bounds(d, e, 0)) } pub(crate) fn is_infinite(e: &Entity) -> bool { matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) } /// Candidates from the spatial index touching `query` (draw order, reversed when `rev`), or every /// entity of the space when it is too small to index (or the query box isn't finite). pub(crate) fn candidates<'a>( d: &'a Drawing, space: &Space, ix: &'a Option>, query: &Bounds2, infinite: bool, rev: bool, ) -> Option> { if let Some(ix) = ix && let Some(c) = ix.query(query, infinite) { let it = move |i: u32| ix.entity(i).map(|e| (e.as_ref(), Some(ix.bounds(i)))); return Some(if rev { Box::new(c.into_iter().rev().filter_map(it)) } else { Box::new(c.into_iter().filter_map(it)) }); } let store = d.space(space)?; if rev { let ents: Vec<&Entity> = store.iter().map(|e| e.as_ref()).collect(); Some(Box::new(ents.into_iter().rev().map(|e| (e, None)))) } else { Some(Box::new(store.iter().map(|e| (e.as_ref(), None)))) } } /// The topmost entity within `aperture` of `p`. pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option { let ix = spatial::index(d, space); pick_with(d, space, &ix, p, aperture) } pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option>, p: Vec2, aperture: f64) -> Option { let probe = Bounds2::new(p, p).expand(aperture); let mut best: Option<(f64, Handle)> = None; for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? { if !selectable(d, e) { continue; } let infinite = is_infinite(e); if !infinite && !bounds_of(d, e, known).expand(aperture).intersects(&probe) { continue; } let dist = entity_distance(d, e, p, aperture / 4.0); if dist <= aperture && best.is_none_or(|(bd, _)| dist < bd - 1e-12) { best = Some((dist, e.handle)); if dist == 0.0 { break; } } } best.map(|(_, h)| h) } /// Every entity within `aperture` of `p`, nearest first and topmost among equals (so the first is /// what [`pick`] chooses): the objects selection cycling lists. pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec { let ix = spatial::index(d, space); let probe = Bounds2::new(p, p).expand(aperture); let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() }; let mut hits: Vec<(f64, Handle)> = cands .filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe))) .map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle)) .filter(|(dist, _)| *dist <= aperture) .collect(); hits.sort_by(|a, b| a.0.total_cmp(&b.0)); hits.into_iter().map(|(_, h)| h).collect() } /// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on /// locked layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 objects in groups. pub fn preview_filtered(d: &Drawing, e: &Entity, filter: u8) -> bool { let on = |bit: u8| filter & bit != 0; (on(1) && d.layer(&e.common.layer).is_some_and(|l| l.locked)) || (on(2) && matches!(&e.kind, EntityKind::Insert(i) if d.block(&i.block).is_some_and(|b| b.xref_path.is_some()))) || (on(4) && matches!(e.kind, EntityKind::Table(_))) || (on(8) && matches!(e.kind, EntityKind::MText(_))) || (on(16) && matches!(e.kind, EntityKind::Hatch(_))) || (on(32) && d.groups.iter().any(|g| g.members.contains(&e.handle))) } fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool { if bx.contains(a) || bx.contains(b) { return true; } let c = bx.corners(); (0..4).any(|i| cadcraft_geom::line_line(&Line::new(a, b), &Line::new(c[i], c[(i + 1) % 4])).is_some()) } /// Window selection (entirely inside) or crossing selection (inside or touching). pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec { let ix = spatial::index(d, space); select_window_with(d, space, &ix, bx, crossing) } pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option>, bx: Bounds2, crossing: bool) -> Vec { let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() }; let tol = window_tol(&bx); cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect() } /// The tessellation tolerance window and crossing tests use for a box. pub(crate) fn window_tol(bx: &Bounds2) -> f64 { (bx.width() + bx.height()).max(1e-9) / 2000.0 } /// Whether a window box (`crossing` false: entirely inside) or a crossing box (inside or /// touching its geometry, not just its bounding box) selects `e`. pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option, bx: &Bounds2, crossing: bool, tol: f64) -> bool { let infinite = is_infinite(e); let eb = bounds_of(d, e, known); if !infinite && bx.contains_box(&eb) { return true; } if !crossing || (!infinite && !eb.intersects(bx)) { return false; } let polys = hit_polylines(d, e, tol); let hit = polys.iter().any(|pl| { if pl.len() == 1 { pl.first().is_some_and(|p| bx.contains(*p)) } else { pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| seg_hits_box(*a, *b, bx))) } }); // A crossing box entirely inside a text/block bounding rectangle also selects it. if hit || (eb.contains_box(bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) { return true; } // SOLID/TRACE are filled polygons. A crossing window wholly within the // fill must select them even when it does not touch their outline. Check // the polygon, not just its bounding rectangle (which can cover empty // space around triangular solids). if matches!(e.kind, EntityKind::Solid(_) | EntityKind::Trace(_)) && eb.contains_box(bx) { return e.kind.prims().iter().any(|prim| match prim { Prim::Fill(poly) => bx.corners().iter().all(|p| cadcraft_geom::point_in_polygon(poly, *p)), _ => false, }); } false } /// Fence selection: entities crossed by the fence polyline. pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec { let ix = spatial::index(d, space); select_fence_with(d, space, &ix, fence) } pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option>, fence: &[Vec2]) -> Vec { let fb = Bounds2::from_points(fence.iter().copied()); let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0; let probe = fb.expand(tol); let Some(cands) = candidates(d, space, ix, &probe, true, false) else { return Vec::new() }; let mut out = Vec::new(); for (e, known) in cands { // Infinite objects have no useful bounds; like window selection, test their geometry directly. if !selectable(d, e) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) { continue; } let polys = hit_polylines(d, e, tol); let hit = fence.windows(2).any(|f| { let (Some(a), Some(b)) = (f.first(), f.get(1)) else { return false }; let fl = Line::new(*a, *b); polys.iter().any(|pl| { pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(p, q)| cadcraft_geom::line_line(&fl, &Line::new(*p, *q)).is_some())) }) }); if hit { out.push(e.handle); } } out } /// Window polygon selection (entities entirely inside `poly`) or crossing polygon selection /// (inside or crossing its edges). The polygon closes back to its first point. pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool) -> Vec { let (Some(first), true) = (poly.first(), poly.len() >= 3) else { return Vec::new() }; let ix = spatial::index(d, space); let pb = Bounds2::from_points(poly.iter().copied()); let tol = (pb.width() + pb.height()).max(1e-9) / 2000.0; let Some(cands) = candidates(d, space, &ix, &pb, crossing, false) else { return Vec::new() }; let edges: Vec = poly.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?))).chain(poly.last().map(|l| Line::new(*l, *first))).collect(); let mut out = Vec::new(); for (e, known) in cands { let infinite = is_infinite(e); if !selectable(d, e) || (infinite && !crossing) { continue; } let eb = bounds_of(d, e, known); if !infinite && if crossing { !eb.intersects(&pb) } else { !pb.contains_box(&eb) } { continue; } let polys = hit_polylines(d, e, tol); let crosses = polys.iter().any(|pl| { pl.windows(2).any(|w| { w.first().zip(w.get(1)).is_some_and(|(p, q)| edges.iter().any(|f| cadcraft_geom::line_line(f, &Line::new(*p, *q)).is_some())) }) }); let mut pts = polys.iter().flatten(); let hit = if crossing { crosses || pts.any(|p| point_in_polygon(poly, *p)) } else { !crosses && !polys.is_empty() && pts.all(|p| point_in_polygon(poly, *p)) }; if hit { out.push(e.handle); } } out } /// The visible objects in `space` of the group called `name` (any case), or `None` when the /// drawing has no such group. pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option> { let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?; let store = d.space(space)?; Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect()) } /// A selection mode typed at "Select objects", collecting its points before it selects. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SelectKind { /// `W`: objects entirely inside two corners, whichever order they come in. Window, /// `C`: objects inside or crossing two corners, whichever order they come in. Crossing, /// `F`: objects crossed by a series of points; Enter ends it. Fence, /// `WP`: objects entirely inside a polygon; Enter closes it. WPolygon, /// `CP`: objects inside or crossing a polygon; Enter closes it. CPolygon, /// `BOX`: two corners; left to right is a window, right to left a crossing. Box, /// `G`: the objects of a group, by name. Group, } /// Most points a fence or polygon collects (typed input is hostile). pub(crate) const MAX_MODE_POINTS: usize = 10_000; /// A typed selection mode in progress and the points given so far. #[derive(Clone, Debug, PartialEq)] pub struct SelectMode { pub kind: SelectKind, pub pts: Vec, } impl SelectMode { /// The mode named by text typed at "Select objects" (`W`, `Window`, `C`, `F`, `WP`, `CP`…). pub fn from_keyword(text: &str) -> Option { let kind = match text.trim().to_ascii_lowercase().as_str() { "w" | "window" => SelectKind::Window, "c" | "crossing" => SelectKind::Crossing, "f" | "fence" => SelectKind::Fence, "wp" | "wpolygon" => SelectKind::WPolygon, "cp" | "cpolygon" => SelectKind::CPolygon, "box" => SelectKind::Box, "g" | "group" => SelectKind::Group, _ => return None, }; Some(SelectMode { kind, pts: Vec::new() }) } fn is_box(&self) -> bool { matches!(self.kind, SelectKind::Window | SelectKind::Crossing | SelectKind::Box) } /// The prompt shown while the mode collects points. pub fn prompt(&self) -> Prompt { let last = self.pts.last().copied(); let (first, next) = match self.kind { SelectKind::Group => return Prompt::new("Enter group name", Accept::TEXT), SelectKind::Window | SelectKind::Crossing | SelectKind::Box => ("Specify first corner", "Specify opposite corner"), SelectKind::Fence => ("Specify first fence point", "Specify next fence point"), SelectKind::WPolygon | SelectKind::CPolygon => ("Specify first polygon point", "Specify endpoint of line"), }; match last { None => Prompt::new(first, Accept::POINT), Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p), Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p), } } /// Add a point. Returns whether the mode is complete (the second corner of W/C). pub fn push(&mut self, p: Vec2) -> bool { if p.is_finite() && self.pts.len() < MAX_MODE_POINTS { self.pts.push(p); } self.is_box() && self.pts.len() >= 2 } /// The objects the mode selects, or `None` while it has too few points. pub fn select(&self, d: &Drawing, space: &Space) -> Option> { match (self.kind, self.pts.as_slice()) { (SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => { Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing)) } (SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)), (SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)), (SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => { Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon)) } _ => None, } } } #[cfg(test)] mod tests { use super::*; use cadcraft_doc::{Common, RayLine}; use cadcraft_geom::Vec3; fn fence_hits(kind: EntityKind, fence: [(f64, f64); 2]) -> usize { let mut d = Drawing::new_metric(); d.add(&Space::Model, Common::default(), kind).unwrap(); let f = [Vec2::new(fence[0].0, fence[0].1), Vec2::new(fence[1].0, fence[1].1)]; select_fence(&d, &Space::Model, &f).len() } #[test] fn fence_selects_crossed_xline_and_ray() { let line = RayLine { base: Vec3::new(0.0, 0.0, 0.0), dir: Vec3::new(1.0, 0.0, 0.0) }; for x in [-5.0, 0.0, 5.0] { assert_eq!(fence_hits(EntityKind::XLine(line.clone()), [(x, -5.0), (x, 5.0)]), 1, "xline at x={x}"); } assert_eq!(fence_hits(EntityKind::Ray(line.clone()), [(5.0, -5.0), (5.0, 5.0)]), 1); assert_eq!(fence_hits(EntityKind::Ray(line.clone()), [(0.0, -5.0), (0.0, 5.0)]), 1); assert_eq!(fence_hits(EntityKind::Ray(line.clone()), [(-5.0, -5.0), (-5.0, 5.0)]), 0); assert_eq!(fence_hits(EntityKind::XLine(line.clone()), [(0.0, 1.0), (0.0, 5.0)]), 0); assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0); } } #[cfg(test)] mod solid_interior_tests { use super::*; fn face(kind: bool, triangular: bool) -> Entity { let a = Vec2::new(0.0, 0.0).to3(0.0); let b = Vec2::new(10.0, 0.0).to3(0.0); let c = Vec2::new(0.0, 10.0).to3(0.0); let d = if triangular { c } else { Vec2::new(10.0, 10.0).to3(0.0) }; let solid = cadcraft_doc::Solid { corners: [a, b, c, d] }; Entity::new(Handle(256), if kind { EntityKind::Solid(solid) } else { EntityKind::Trace(solid) }) } #[test] fn crossing_window_inside_solid_and_trace_selects_fill() { let inside = Bounds2::new(Vec2::new(4.0, 4.0), Vec2::new(6.0, 6.0)); for solid in [true, false] { let mut d = Drawing::default(); let e = face(solid, false); assert!(in_window(&d, &e, None, &inside, true, 0.01)); assert!(!in_window(&d, &e, None, &inside, false, 0.01)); d.model.push(e); assert_eq!(select_window(&d, &Space::Model, inside, true), vec![Handle(256)]); assert!(select_window(&d, &Space::Model, inside, false).is_empty()); } } #[test] fn crossing_window_in_triangular_bbox_but_outside_fill_does_not_select() { let d = Drawing::default(); let e = face(true, true); let empty = Bounds2::new(Vec2::new(7.0, 7.0), Vec2::new(8.0, 8.0)); assert!(!in_window(&d, &e, None, &empty, true, 0.01)); let inside = Bounds2::new(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0)); assert!(in_window(&d, &e, None, &inside, true, 0.01)); } #[test] fn pick_in_triangular_bbox_but_outside_fill_does_not_select() { let mut d = Drawing::default(); d.model.push(face(true, true)); let at = |x: f64, y: f64| pick(&d, &Space::Model, Vec2::new(x, y), 0.25); // The empty corner of the bounding rectangle, 5.66 from the hypotenuse. assert_eq!(at(9.0, 9.0), None); // Inside the fill, farther than the aperture from every edge. assert_eq!(at(2.0, 2.0), Some(Handle(256))); // Outside the fill but within the aperture of the hypotenuse. assert_eq!(at(5.1, 5.1), Some(Handle(256))); } } #[cfg(test)] mod hatch_pattern_pick_tests { use super::*; use cadcraft_doc::{Common, Hatch, HatchLoop}; use cadcraft_geom::PolyVertex; fn square(lo: f64, hi: f64, outer: bool) -> HatchLoop { let vertices = [(lo, lo), (hi, lo), (hi, hi), (lo, hi)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); HatchLoop { vertices, outer, sources: Vec::new() } } fn ansi31_hatch(loops: Vec) -> Drawing { let mut d = Drawing::new_metric(); let hatch = Hatch { pattern: "ANSI31".into(), solid: false, loops, scale: 1.0, angle: 0.0, associative: false, style: 0, elevation: 0.0, gradient: None, origin: Vec2::ZERO, background: None, pattern_lines: Vec::new(), }; d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch)).unwrap(); d } #[test] fn pattern_hatch_is_picked_on_a_visible_stroke() { let d = ansi31_hatch(vec![square(0.0, 10.0, true)]); // The ANSI31 diagonal passes through the middle of the square. assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_some()); // The boundary is still pickable and a point outside is not. assert!(pick(&d, &Space::Model, Vec2::new(5.0, 0.0), 0.05).is_some()); assert!(pick(&d, &Space::Model, Vec2::new(15.0, 5.0), 0.05).is_none()); } #[test] fn pattern_hatch_island_stays_empty() { // The diagonal is clipped to [0,0]-[4,4] and [6,6]-[10,10] around the island. let d = ansi31_hatch(vec![square(0.0, 10.0, true), square(4.0, 6.0, false)]); assert!(pick(&d, &Space::Model, Vec2::new(1.0, 1.0), 0.05).is_some()); assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_none()); } fn solid_hatch(loops: Vec, style: u8) -> Drawing { let mut d = Drawing::new_metric(); let hatch = Hatch { pattern: "SOLID".into(), solid: true, loops, scale: 1.0, angle: 0.0, associative: false, style, elevation: 0.0, gradient: None, origin: Vec2::ZERO, background: None, pattern_lines: Vec::new(), }; d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch)).unwrap(); d } #[test] fn solid_hatch_is_picked_inside_its_fill_not_its_bounding_rectangle() { let tri = HatchLoop { vertices: [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(), outer: true, sources: Vec::new(), }; let d = solid_hatch(vec![tri], 0); // The empty corner of the bounding rectangle, 5.66 from the hypotenuse. assert!(pick(&d, &Space::Model, Vec2::new(9.0, 9.0), 0.25).is_none()); assert!(pick(&d, &Space::Model, Vec2::new(2.0, 2.0), 0.25).is_some()); // Outside the fill but within the aperture of the hypotenuse. assert!(pick(&d, &Space::Model, Vec2::new(5.1, 5.1), 0.25).is_some()); } #[test] fn solid_hatch_island_follows_the_island_style() { let loops = || vec![square(0.0, 10.0, true), square(4.0, 6.0, false)]; // Normal style leaves the island empty, as it is drawn. let d = solid_hatch(loops(), 0); assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_none()); assert!(pick(&d, &Space::Model, Vec2::new(2.0, 2.0), 0.05).is_some()); // Ignore style fills over the island. let d = solid_hatch(loops(), 2); assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_some()); } }