diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 6a79111..cb6733d 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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(), diff --git a/crates/doc/src/header.rs b/crates/doc/src/header.rs index fb4702f..fe3352f 100644 --- a/crates/doc/src/header.rs +++ b/crates/doc/src/header.rs @@ -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 } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42e7515..4a325ee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index ccfbaf1..c1d917d 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -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 { + 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] diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 17cdc97..e899d53 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -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 { 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); diff --git a/crates/engine/tests/snap_annotations.rs b/crates/engine/tests/snap_annotations.rs new file mode 100644 index 0000000..97bf0ac --- /dev/null +++ b/crates/engine/tests/snap_annotations.rs @@ -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)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4358e9a..1ee8436 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 {