Dimensions snap; justified text snaps and grips at its alignment point; grid snap follows SNAPBASE/SNAPANG; OSNAPHATCH (fixes #334) (#337)

- A dimension snaps to what it draws: the objects of its block (read from a
  file), expanded like block references, or else the lines generated from it
  (Endpoint on their ends, Midpoint, Intersection, Nearest); Node on its
  definition points.
- TEXT justified other than left-baseline (as read from DXF, group 11)
  snaps (Insertion) and grips at its alignment point; attributes too.
- The snap grid starts at SNAPBASE and turns by SNAPANG (drawing variables,
  defaults 0,0 and 0 degrees). SETVAR takes `x,y` for point variables.
- Hatches are left out of object snaps unless OSNAPHATCH is 1 (new session
  variable, default 0 as in AutoCAD).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 22:55:12 +02:00 committed by GitHub
parent b1a78fbb8f
commit 6a15ba2a7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 220 additions and 13 deletions

View File

@ -216,6 +216,17 @@ pub struct Text {
#[serde(default)]
pub valign: VAlign,
}
impl Text {
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
/// aligned and fit text.
pub fn justify_point(&self) -> Vec2 {
match (self.halign, self.valign) {
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
_ => self.align_pt.unwrap_or(self.insert).xy(),
}
}
}
fn one() -> f64 {
1.0
}
@ -977,10 +988,10 @@ impl EntityKind {
}
}
EntityKind::Ray(r) | EntityKind::XLine(r) => vec![r.base.xy(), r.base.xy() + r.dir.xy()],
EntityKind::Text(t) => vec![t.insert.xy()],
EntityKind::Text(t) => vec![t.justify_point()],
EntityKind::MText(t) => vec![t.insert.xy()],
EntityKind::AttDef(a) => vec![a.text.insert.xy()],
EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.insert.xy())).collect(),
EntityKind::AttDef(a) => vec![a.text.justify_point()],
EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.justify_point())).collect(),
EntityKind::Dimension(d) => vec![d.p13.xy(), d.p14.xy(), d.defpt.xy(), d.text_mid.xy()],
EntityKind::Leader(l) => l.vertices.iter().map(|v| v.xy()).collect(),
EntityKind::MLeader(m) => m.leaders.iter().flat_map(|l| l.iter().map(|v| v.xy())).chain(std::iter::once(m.landing.xy())).collect(),

View File

@ -115,6 +115,9 @@ impl Header {
h.set_f64("ELEVATION", 0.0);
h.set_i64("LWDISPLAY", 0);
h.set("INSBASE", HVal::Point(Vec3::ZERO));
// Snap grid origin and rotation (SNAPANG in degrees).
h.set("SNAPBASE", HVal::Point(Vec3::ZERO));
h.set_f64("SNAPANG", 0.0);
h
}

View File

@ -219,6 +219,8 @@ pub struct Settings {
pub gridmode: bool,
pub snapmode: bool,
pub snapunit: Vec2,
/// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD).
pub osnaphatch: bool,
pub gridunit: Vec2,
/// Major grid line every N minor lines (GRIDMAJOR).
pub gridmajor: u32,
@ -260,6 +262,7 @@ impl Default for Settings {
gridmode: true,
snapmode: false,
snapunit: Vec2::new(0.5, 0.5),
osnaphatch: false,
gridunit: Vec2::new(0.5, 0.5),
gridmajor: 5,
dynmode: true,

View File

@ -22,6 +22,8 @@ pub mod mode {
pub const PAR: u32 = 8192;
/// Running snaps temporarily off (F3).
pub const OFF: u32 = 16384;
/// Not an OSMODE bit: snaps also find hatch objects (OSNAPHATCH = 1).
pub const HATCH: u32 = 1 << 16;
pub const ALL: [(u32, &str); 14] = [
(END, "Endpoint"),
(MID, "Midpoint"),
@ -164,11 +166,11 @@ pub(crate) fn osnap_with(
}
if osmode & mode::INS != 0 {
match &e.kind {
EntityKind::Text(t) => cands.push((mode::INS, t.insert.xy())),
EntityKind::Text(t) => cands.push((mode::INS, t.justify_point())),
EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())),
EntityKind::Insert(i) => {
cands.push((mode::INS, i.insert.xy()));
cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.insert.xy())));
cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.justify_point())));
}
_ => {}
}
@ -179,6 +181,9 @@ pub(crate) fn osnap_with(
if let EntityKind::Insert(ins) = &e.kind {
block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, &mut |k, m| sink.object(k, Some(m)));
}
if let EntityKind::Dimension(dm) = &e.kind {
sink.dimension(d, dm, &mut budget);
}
}
// Intersections between nearby primitives.
if osmode & (mode::INT | mode::APP) != 0 {
@ -277,6 +282,10 @@ impl Sink<'_> {
/// (whose primitives far from the cursor are skipped).
fn object(&mut self, kind: &EntityKind, xf: Option<&Mat3>) {
let osmode = self.osmode;
// OSNAPHATCH = 0: hatches are left out.
if osmode & mode::HATCH == 0 && matches!(kind, EntityKind::Hatch(_)) {
return;
}
let at = |p: Vec2| xf.map_or(p, |m| m.apply(p));
if osmode & mode::END != 0
&& let EntityKind::Solid(s) | EntityKind::Trace(s) = kind
@ -375,6 +384,67 @@ impl Sink<'_> {
}
}
impl Sink<'_> {
/// A dimension snaps to what it draws, as in AutoCAD (into its block): the objects of its
/// block when the drawing has one (read from a file), else the lines generated from it
/// (Endpoint on their ends); Node on its definition points.
fn dimension(&mut self, d: &Drawing, dm: &cadcraft_doc::Dimension, budget: &mut usize) {
match dm.block.as_ref().and_then(|n| d.block(n)) {
Some(blk) => {
// The block holds world geometry: a reference at its own base point places it as is.
let ins = Insert {
block: blk.name.clone(),
insert: blk.base,
scale: cadcraft_geom::Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, &mut |k, m| self.object(k, Some(m)));
}
None => {
let g = cadcraft_render::dimension_in(d, dm);
for line in &g.lines {
let (Some(a), Some(b)) = (line.first(), line.last()) else { continue };
if self.osmode & mode::END != 0 {
self.cands.extend([(mode::END, *a), (mode::END, *b)]);
}
if self.osmode & mode::MID != 0 && line.len() == 2 {
self.cands.push((mode::MID, (*a + *b) * 0.5));
}
for w in line.windows(2) {
if let [p, q] = w {
let s = Prim::Seg(Segment::Line(cadcraft_geom::Line::new(*p, *q)));
if prim_near(&s, &self.probe, self.aperture) {
self.near.push(s);
}
}
}
}
}
}
if self.osmode & mode::NOD != 0 {
self.cands.extend(dim_def_points(dm).into_iter().map(|p| (mode::NOD, p)));
}
}
}
/// The definition points of a dimension that its kind uses.
fn dim_def_points(dm: &cadcraft_doc::Dimension) -> Vec<Vec2> {
use cadcraft_doc::DimKind;
let pts = match dm.kind {
DimKind::Linear { .. } | DimKind::Aligned | DimKind::ArcLength => vec![dm.defpt, dm.p13, dm.p14],
DimKind::Radius | DimKind::Diameter => vec![dm.defpt, dm.p15],
DimKind::Angular3P => vec![dm.defpt, dm.p13, dm.p14, dm.p15],
DimKind::Angular => vec![dm.defpt, dm.p13, dm.p14, dm.p15, dm.p16],
DimKind::Ordinate { .. } => vec![dm.p13, dm.p14],
};
pts.into_iter().map(|p| p.xy()).filter(|p| p.is_finite()).collect()
}
/// Calls `f` with each object drawn by a block reference and the transform placing it, nested
/// references expanded (within [`MAX_BLOCK_DEPTH`] levels and `budget` objects); objects that
/// aren't drawn (hidden, on a layer that is off or frozen, attribute definitions) are left out.
@ -684,11 +754,21 @@ pub fn polar(base: Vec2, p: Vec2, inc: f64, tol: f64) -> Option<(Vec2, f64)> {
}
}
/// Grid snap.
pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2) -> Vec2 {
let sx = if unit.x > 1e-12 { ((p.x - origin.x) / unit.x).round() * unit.x + origin.x } else { p.x };
let sy = if unit.y > 1e-12 { ((p.y - origin.y) / unit.y).round() * unit.y + origin.y } else { p.y };
Vec2::new(sx, sy)
/// Grid snap: the nearest point of the grid of `unit` spacing through `origin`, turned by
/// `angle` (radians) about it.
pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2, angle: f64) -> Vec2 {
let angle = if angle.is_finite() { angle } else { 0.0 };
let q = (p - origin).rotate(-angle);
let sx = if unit.x > 1e-12 { (q.x / unit.x).round() * unit.x } else { q.x };
let sy = if unit.y > 1e-12 { (q.y / unit.y).round() * unit.y } else { q.y };
let s = origin + Vec2::new(sx, sy).rotate(angle);
if s.is_finite() { s } else { p }
}
/// The snap grid's origin (SNAPBASE) and rotation in radians (SNAPANG, in degrees) of a drawing.
pub fn grid_frame(d: &Drawing) -> (Vec2, f64) {
let base = d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO);
(base, d.header.f64("SNAPANG", 0.0).to_radians())
}
#[cfg(test)]
@ -746,7 +826,7 @@ mod tests {
let (p, a) = polar(Vec2::ZERO, Vec2::new(5.0, 5.1), 45f64.to_radians(), 3f64.to_radians()).unwrap();
assert!((a - PI / 4.0).abs() < 1e-12);
assert!((p.x - p.y).abs() < 1e-9);
assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO), Vec2::new(0.5, 1.5));
assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO, 0.0), Vec2::new(0.5, 1.5));
}
#[test]

View File

@ -6,6 +6,7 @@ use crate::{EngineError, Result, Session};
const SESSION_VARS: &[&str] = &[
"OSMODE",
"OSNAPHATCH",
"ORTHOMODE",
"POLARMODE",
"POLARANG",
@ -36,6 +37,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
let st = &s.settings;
let v = match n.as_str() {
"OSMODE" => json!(st.osmode),
"OSNAPHATCH" => json!(i32::from(st.osnaphatch)),
"ORTHOMODE" => json!(i32::from(st.orthomode)),
"POLARMODE" => json!(i32::from(st.polarmode)),
"POLARANG" => json!(st.polarang.to_degrees()),
@ -130,6 +132,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
let st = &mut s.settings;
match n.as_str() {
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
"ORTHOMODE" => st.orthomode = as_bool(v).ok_or_else(bad)?,
"POLARMODE" => st.polarmode = as_bool(v).ok_or_else(bad)?,
"POLARANG" => st.polarang = as_f64(v).filter(|a| *a > 0.0).ok_or_else(bad)?.to_radians(),
@ -192,6 +195,11 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
d.header.set_i64(&n, i);
return Ok(());
}
// A point variable takes `x,y` typed at the command line too.
Some(cadcraft_doc::HVal::Point(_)) => {
d.header.set(&n, cadcraft_doc::HVal::Point(as_pt(v).ok_or_else(bad)?.to3(0.0)));
return Ok(());
}
_ => {}
}
d.header.set(&n, val);

View File

@ -0,0 +1,100 @@
//! Snaps on dimensions, justified text and hatches; the snap grid's base and angle (#334).
use cadcraft_doc::{Common, EntityKind, HAlign, Space, Text, VAlign};
use cadcraft_engine::Session;
use cadcraft_engine::snap::{self, mode};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::json;
/// The snap of `modes` near `p` (a hair off it, inside a 0.1 aperture).
fn snap_at(s: &Session, p: Vec2, modes: u32) -> Option<(Vec2, u32)> {
snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.02, -0.01), 0.1, modes, None, false).map(|h| (h.point, h.mode))
}
fn handle(s: &mut Session, cmd: &str, p: serde_json::Value) -> String {
let r = s.execute(cmd, &p).unwrap();
r.get("handle").unwrap_or(&r["handles"][0]).as_str().unwrap().to_string()
}
#[test]
fn dimensions_snap_to_their_lines_and_definition_points() {
let mut s = Session::new();
s.cmdline("dimlinear 0,0 10,0 5,3").unwrap();
assert!(s.running.is_none());
// Endpoint: the dimension line's end; Node: the extension line origins.
assert_eq!(snap_at(&s, Vec2::new(10.0, 3.0), mode::END), Some((Vec2::new(10.0, 3.0), mode::END)));
assert_eq!(snap_at(&s, Vec2::new(0.0, 0.0), mode::NOD), Some((Vec2::new(0.0, 0.0), mode::NOD)));
assert_eq!(snap_at(&s, Vec2::new(10.0, 0.0), mode::NOD), Some((Vec2::new(10.0, 0.0), mode::NOD)));
// A dimension with a block (as read from a file) snaps to the block's objects.
let l = handle(&mut s, "line", json!({ "points": [[0, 7], [10, 7]] }));
s.execute("block", &json!({ "name": "DIMGEOM", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap();
let d = s.doc_mut().unwrap();
let dim = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).unwrap().handle;
d.model.modify(dim, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.block = Some("DIMGEOM".into());
}
});
assert_eq!(snap_at(&s, Vec2::new(10.0, 7.0), mode::END), Some((Vec2::new(10.0, 7.0), mode::END)));
}
#[test]
fn justified_text_snaps_and_grips_at_its_alignment_point() {
let mut s = Session::new();
// As read from DXF: a right-justified text keeps group 10 (start) and group 11 (alignment).
let t = Text {
insert: Vec3::new(0.0, 0.0, 0.0),
align_pt: Some(Vec3::new(10.0, 0.0, 0.0)),
height: 1.0,
value: "ABCDEFGHIJ".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Right,
valign: VAlign::Baseline,
};
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Text(t.clone())).unwrap();
assert_eq!(snap_at(&s, Vec2::new(10.0, 0.0), mode::INS), Some((Vec2::new(10.0, 0.0), mode::INS)));
assert_eq!(snap_at(&s, Vec2::new(0.0, 0.0), mode::INS), None);
assert_eq!(EntityKind::Text(t.clone()).grips(), vec![Vec2::new(10.0, 0.0)]);
// Left-baseline and fit text: the start point.
for (h, v) in [(HAlign::Left, VAlign::Baseline), (HAlign::Fit, VAlign::Baseline)] {
assert_eq!(Text { halign: h, valign: v, ..t.clone() }.justify_point(), Vec2::new(0.0, 0.0));
}
assert_eq!(Text { halign: HAlign::Left, valign: VAlign::Top, ..t.clone() }.justify_point(), Vec2::new(10.0, 0.0));
}
#[test]
fn hatches_snap_only_with_osnaphatch() {
let mut s = Session::new();
let r = handle(&mut s, "rectang", json!({ "p1": [0, 0], "p2": [10, 5] }));
s.execute("hatch", &json!({ "handles": [r] })).unwrap();
s.execute("erase", &json!({ "handles": [r] })).unwrap();
assert!(matches!(s.doc().unwrap().model.iter().last().map(|e| &e.kind), Some(EntityKind::Hatch(_))));
assert_eq!(snap_at(&s, Vec2::new(10.0, 5.0), mode::END), None);
assert_eq!(s.execute("getvar", &json!({ "name": "OSNAPHATCH" })).unwrap()["value"], json!(0));
s.cmdline("setvar osnaphatch 1").unwrap();
assert!(s.settings.osnaphatch);
let modes = mode::END | mode::HATCH;
assert_eq!(snap_at(&s, Vec2::new(10.0, 5.0), modes).map(|h| h.0), Some(Vec2::new(10.0, 5.0)));
}
#[test]
fn grid_snap_follows_snapbase_and_snapang() {
let mut s = Session::new();
assert_eq!(snap::grid_frame(s.doc().unwrap()), (Vec2::ZERO, 0.0));
s.cmdline("setvar snapbase 0.5,0.25").unwrap();
s.cmdline("setvar snapang 30").unwrap();
let (origin, angle) = snap::grid_frame(s.doc().unwrap());
assert_eq!(origin, Vec2::new(0.5, 0.25));
assert!((angle - 30f64.to_radians()).abs() < 1e-12);
// Two units along the turned x axis and one along its y axis, nudged off the grid.
let (u, v) = (Vec2::from_angle(angle), Vec2::from_angle(angle).perp());
let on = origin + u * 2.0 + v * 1.0;
let p = snap::grid_snap(on + Vec2::new(0.1, -0.15), Vec2::new(1.0, 1.0), origin, angle);
assert!(p.near(on, 1e-12), "{p:?} vs {on:?}");
// Unturned, from SNAPBASE.
assert_eq!(snap::grid_snap(Vec2::new(1.4, 1.1), Vec2::new(1.0, 1.0), origin, 0.0), Vec2::new(1.5, 1.25));
}

View File

@ -476,14 +476,16 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 {
}
let ap = s.aperture / xf.scale;
if let Ok(st) = app.session.state()
&& let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base, deferred)
&& let Some(hit) =
snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode | if s.osnaphatch { snap::mode::HATCH } else { 0 }, base, deferred)
{
app.canvas.snap = Some(hit);
return hit.point;
}
let mut p = raw;
if s.snapmode {
p = snap::grid_snap(p, s.snapunit, Vec2::ZERO);
let (origin, angle) = app.session.doc().map(snap::grid_frame).unwrap_or((Vec2::ZERO, 0.0));
p = snap::grid_snap(p, s.snapunit, origin, angle);
}
if let Some(b) = base {
if s.orthomode {