Pick dimensions and multileaders anywhere on their text (#807)

Plain TEXT and MTEXT are picked anywhere inside their box, but a
dimension or multileader was only found near its lines: its hit geometry
is built without text. Hovering or clicking the words of a dimension found
nothing (on a survey plan, 2 of 34 letters of a dimension's text).

A point inside the box around the words, turned with the text, now picks
the object. A dimension with a block, as AutoCAD saves them, is drawn
from that block, so its text box comes from the TEXT and MTEXT in the
block, not from the text the dimension's own geometry would place (on the
same plan that was a different place, eight times smaller). A
multileader's text box comes from its MTEXT layout.

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 04:37:16 -04:00
1 parent 853b2915b6
commit 5ce44acfb2
2 files changed
+159 -1

No files matched your search

+34 -1
View File
@@ -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<Item = Vec2>, 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)
}
+125
View File
@@ -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<Vec2>, 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");
}