diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 2f38205..16dcc55 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -91,7 +91,7 @@ pub fn entity_distance(d: &Drawing, e: &Entity, p: Vec2, tol: f64) -> f64 { let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Table(_)) || matches!(e.kind, EntityKind::Tolerance(_)) || matches!(&e.kind, EntityKind::Hatch(h) if h.solid); - if filled && entity_bounds(d, e, 0).contains(p) { + if (filled && entity_bounds(d, e, 0).contains(p)) || in_text(d, e, p) { best = 0.0; } // A SOLID fills its polygon, not its bounding rectangle (half of which is empty for a triangle). @@ -101,6 +101,39 @@ pub fn entity_distance(d: &Drawing, e: &Entity, p: Vec2, tol: f64) -> f64 { 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) } diff --git a/crates/engine/tests/pick_text.rs b/crates/engine/tests/pick_text.rs new file mode 100644 index 0000000..b0df6d8 --- /dev/null +++ b/crates/engine/tests/pick_text.rs @@ -0,0 +1,125 @@ +//! Picking (and the hover highlight, which picks the same way) finds dimensions and multileaders +//! anywhere on their words, as plain text is found anywhere inside its box. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle, Space}; +use cadcraft_engine::geom::{Bounds2, Vec2}; +use cadcraft_engine::render; +use cadcraft_engine::select::pick; +use serde_json::json; + +/// The last entity of `kind` and the box (centre, half size along the text, angle) of its words. +fn text_of(s: &Session, kind: &str) -> (Handle, Vec2, Vec2, f64) { + let d = s.doc().unwrap(); + let e = d.model.iter().filter(|e| format!("{:?}", e.kind).starts_with(kind)).last().unwrap(); + let (pts, angle): (Vec, f64) = match &e.kind { + EntityKind::Dimension(dm) => { + let g = render::dimension_in(d, dm); + (g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied().collect(), g.text_angle) + } + EntityKind::MLeader(m) => { + let t = m.text.as_ref().unwrap(); + (render::layout_mtext_entity(d, t).strokes.into_iter().flatten().collect(), t.rotation) + } + _ => unreachable!(), + }; + let b = Bounds2::from_points(pts.iter().map(|q| q.rotate(-angle))); + (e.handle, b.center().rotate(angle), Vec2::new(b.width() / 2.0, b.height() / 2.0), angle) +} + +/// A point `(u, v)` half-sizes from the text box's centre, along and across the text. +fn at(c: Vec2, half: Vec2, angle: f64, u: f64, v: f64) -> Vec2 { + c + Vec2::new(half.x * u, half.y * v).rotate(angle) +} + +#[test] +fn dimension_and_multileader_text_is_picked_anywhere_on_its_words() { + let mut s = Session::new(); + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [100, 0], "at": [50, 30], "text": "TWO WORDS HERE"})).unwrap(); + let (h, c, half, angle) = text_of(&s, "Dimension"); + let tiny = half.y * 0.01; + let d = s.doc().unwrap(); + // Between letters, at the ends and the corners of the words: all the dimension. + for (u, v) in [(0.0, 0.0), (0.33, 0.1), (-0.9, -0.8), (0.9, 0.8), (0.5, -0.5)] { + assert_eq!(pick(d, &Space::Model, at(c, half, angle, u, v), tiny), Some(h), "({u}, {v})"); + } + assert_eq!(pick(d, &Space::Model, at(c, half, angle, 0.0, 6.0), tiny), None, "well above the words: nothing"); + + s.execute("mleader", &json!({"points": [[200, 0], [220, 20]], "text": "LEADER NOTE"})).unwrap(); + let (h, c, half, angle) = text_of(&s, "MLeader"); + let d = s.doc().unwrap(); + for (u, v) in [(0.0, 0.0), (0.4, 0.2), (-0.8, -0.6)] { + assert_eq!(pick(d, &Space::Model, at(c, half, angle, u, v), half.y * 0.01), Some(h), "leader text ({u}, {v})"); + } +} + +#[test] +fn slanted_dimension_text_is_picked_inside_its_turned_box_only() { + let mut s = Session::new(); + // Text along the dimension line (not kept horizontal), as in most surveying styles. + for name in ["DIMTIH", "DIMTOH"] { + s.execute("setvar", &json!({"name": name, "value": 0})).unwrap(); + } + s.execute("dimaligned", &json!({"p1": [0, 0], "p2": [100, 100], "at": [40, 60], "text": "SLANTED LABEL"})).unwrap(); + let (h, c, half, angle) = text_of(&s, "Dimension"); + assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 1e-6, "aligned with the 45° line: {angle}"); + let d = s.doc().unwrap(); + let tiny = half.y * 0.01; + assert_eq!(pick(d, &Space::Model, at(c, half, angle, 0.6, 0.0), tiny), Some(h)); + // Inside the axis-aligned box around the turned words, but off them (and off the lines). + let off = at(c, half, angle, 0.3, 4.0); + let aabb = Bounds2::from_points([ + at(c, half, angle, -1.0, -1.0), + at(c, half, angle, 1.0, -1.0), + at(c, half, angle, 1.0, 1.0), + at(c, half, angle, -1.0, 1.0), + ]); + assert!(aabb.contains(off), "the probe sits in the loose box"); + assert_ne!(pick(d, &Space::Model, off, tiny), Some(h), "but not on the words"); +} + +#[test] +fn a_dimension_drawn_from_its_block_is_picked_on_the_block_text() { + // As AutoCAD saves dimensions: the geometry in an anonymous block, here with the text moved + // well away from where the dimension's own geometry would set it. + let mut s = Session::new(); + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [100, 0], "at": [50, 30]})).unwrap(); + s.execute("mtext", &json!({"at": [400, 400], "text": "MOVED TEXT", "height": 5})).unwrap(); + let d = s.doc_mut().unwrap(); + let text = d.model.iter().find(|e| matches!(e.kind, EntityKind::MText(_))).unwrap().as_ref().clone(); + let dim = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).unwrap().handle; + d.model.remove(text.handle); + let mut entities = cadcraft_engine::doc::EntityStore::new(); + entities.push(text); + let block = cadcraft_engine::doc::Block { + xref_overlay: false, + xref_status: Default::default(), + name: "*D1".into(), + annotative: false, + base: Default::default(), + entities, + description: String::new(), + anonymous: true, + xref_path: None, + explodable: true, + units: 0, + array: None, + }; + d.blocks.insert("*D1".into(), std::sync::Arc::new(block)); + d.model.modify(dim, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.block = Some("*D1".into()); + } + }); + let d = s.doc().unwrap(); + let m = render::layout_mtext_entity( + d, + match &d.block("*D1").unwrap().entities.iter().next().unwrap().kind { + EntityKind::MText(t) => t, + _ => unreachable!(), + }, + ); + let mid = m.bounds.center(); + assert_eq!(pick(d, &Space::Model, mid, 0.01), Some(dim), "on the block's text"); + assert_eq!(pick(d, &Space::Model, Vec2::new(50.0, 33.0), 0.01), None, "not where the dimension would have set it"); +}