From 824ae58d913e5abc42672c44fb11839ecc6c2293 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:58 +0200 Subject: [PATCH] Object snaps find the geometry inside block references (fixes #314) (#318) Running snaps only saw a block reference's insertion point. Now the objects inside it snap where they are drawn: nested references and MINSERT arrays are expanded, placed exactly by the reference's transform (non-uniform scale turns circles and arcs into ellipses), and hidden objects or objects on layers that are off are left out. Insertion also snaps to visible attributes. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/snap.rs | 319 +++++++++++++++++++++-------- crates/engine/tests/snap_blocks.rs | 129 ++++++++++++ 2 files changed, 363 insertions(+), 85 deletions(-) create mode 100644 crates/engine/tests/snap_blocks.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 3958ec7..e1f6e33 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -1,7 +1,7 @@ //! Object snaps (OSNAP), polar tracking and ortho. -use cadcraft_doc::{Drawing, EntityKind, Prim, Space}; -use cadcraft_geom::{Bounds2, Circle, PI, Segment, Vec2, intersect_ext}; +use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space}; +use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext}; use serde::Serialize; /// OSMODE bits. @@ -149,6 +149,7 @@ pub(crate) fn osnap_with( let mut cands: Vec<(u32, Vec2)> = Vec::new(); let mut near_prims: Vec = Vec::new(); let mut count = 0usize; + let mut budget = MAX_BLOCK_LEAVES; for (e, known) in crate::select::candidates(d, space, ix, &probe.expand(aperture), true, false)? { if !d.is_visible(e) { continue; @@ -165,92 +166,18 @@ pub(crate) fn osnap_with( match &e.kind { EntityKind::Text(t) => cands.push((mode::INS, t.insert.xy())), EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())), - EntityKind::Insert(i) => cands.push((mode::INS, i.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()))); + } _ => {} } } - if osmode & mode::END != 0 - && let EntityKind::Solid(s) | EntityKind::Trace(s) = &e.kind - { - cands.extend(s.corners.iter().map(|c| (mode::END, c.xy()))); - } - for p in e.kind.prims() { - match &p { - Prim::Seg(s) => { - if osmode & mode::END != 0 { - cands.push((mode::END, s.start())); - cands.push((mode::END, s.end())); - } - if osmode & mode::MID != 0 { - cands.push((mode::MID, s.mid())); - } - if let Segment::Arc { arc, .. } = s { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, arc.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - let a = k as f64 * PI / 2.0; - if arc.contains_angle(a) { - cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a))); - } - } - } - } - } - Prim::Circle(c) => { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, c.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0))); - } - } - } - Prim::Ellipse(el) => { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, el.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - cands.push((mode::QUA, el.at_param(k as f64 * PI / 2.0))); - } - } - if osmode & mode::END != 0 && !el.is_full() { - cands.push((mode::END, el.at_param(el.start))); - cands.push((mode::END, el.at_param(el.end))); - } - } - Prim::Spline(s) => { - if osmode & mode::END != 0 && !s.closed { - let (lo, hi) = s.domain(); - cands.push((mode::END, s.eval(lo))); - cands.push((mode::END, s.eval(hi))); - } - } - Prim::Point(pt) => { - if osmode & mode::NOD != 0 && matches!(e.kind, EntityKind::Point(_)) { - cands.push((mode::NOD, *pt)); - } - } - Prim::Infinite { base, .. } => { - if osmode & mode::END != 0 && matches!(e.kind, EntityKind::Ray(_)) { - cands.push((mode::END, *base)); - } - } - Prim::Fill(_) => {} - } - near_prims.push(p); - } - if osmode & mode::GCEN != 0 - && let EntityKind::LwPolyline(pl) = &e.kind - && pl.closed - { - let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(aperture / 10.0); - if let Some(c) = centroid(&pts) { - cands.push((mode::GCEN, c)); - } + let mut sink = Sink { osmode, aperture, probe, cands: &mut cands, near: &mut near_prims }; + sink.object(&e.kind, None); + // A block reference snaps to the objects inside it, placed by its transform. + if let EntityKind::Insert(ins) = &e.kind { + block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, &mut |k, m| sink.object(k, Some(m))); } } // Intersections between nearby primitives. @@ -333,6 +260,228 @@ pub(crate) fn osnap_with( None } +/// Upper bound on the block-reference objects one snap query looks at (hostile files). +const MAX_BLOCK_LEAVES: usize = 20_000; + +/// Collects snap candidates and the primitives near the cursor. +struct Sink<'a> { + osmode: u32, + aperture: f64, + probe: Bounds2, + cands: &'a mut Vec<(u32, Vec2)>, + near: &'a mut Vec, +} + +impl Sink<'_> { + /// The snap points of one object; `xf` places an object from inside a block reference + /// (whose primitives far from the cursor are skipped). + fn object(&mut self, kind: &EntityKind, xf: Option<&Mat3>) { + let osmode = self.osmode; + 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 + { + self.cands.extend(s.corners.iter().map(|c| (mode::END, at(c.xy())))); + } + for p in kind.prims() { + let p = match xf { + Some(m) => { + let p = xform_prim(&p, m); + if !prim_near(&p, &self.probe, self.aperture) { + continue; + } + p + } + None => p, + }; + let cands = &mut *self.cands; + match &p { + Prim::Seg(s) => { + if osmode & mode::END != 0 { + cands.push((mode::END, s.start())); + cands.push((mode::END, s.end())); + } + if osmode & mode::MID != 0 { + cands.push((mode::MID, s.mid())); + } + if let Segment::Arc { arc, .. } = s { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, arc.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + let a = k as f64 * PI / 2.0; + if arc.contains_angle(a) { + cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a))); + } + } + } + } + } + Prim::Circle(c) => { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, c.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0))); + } + } + } + Prim::Ellipse(el) => { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, el.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + cands.push((mode::QUA, el.at_param(k as f64 * PI / 2.0))); + } + } + if osmode & mode::END != 0 && !el.is_full() { + cands.push((mode::END, el.at_param(el.start))); + cands.push((mode::END, el.at_param(el.end))); + } + } + Prim::Spline(s) => { + if osmode & mode::END != 0 && !s.closed { + let (lo, hi) = s.domain(); + cands.push((mode::END, s.eval(lo))); + cands.push((mode::END, s.eval(hi))); + } + } + Prim::Point(pt) => { + if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) { + cands.push((mode::NOD, *pt)); + } + } + Prim::Infinite { base, .. } => { + if osmode & mode::END != 0 && matches!(kind, EntityKind::Ray(_)) { + cands.push((mode::END, *base)); + } + } + Prim::Fill(_) => {} + } + self.near.push(p); + } + if osmode & mode::GCEN != 0 + && let EntityKind::LwPolyline(pl) = kind + && pl.closed + { + let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0); + if let Some(c) = centroid(&pts) { + self.cands.push((mode::GCEN, at(c))); + } + } + } +} + +/// 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. +fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut usize, f: &mut dyn FnMut(&EntityKind, &Mat3)) { + if depth >= MAX_BLOCK_DEPTH { + return; + } + let Some(blk) = d.block(&ins.block) else { return }; + let (cols, rows) = (ins.cols.clamp(1, 10_000), ins.rows.clamp(1, 10_000)); + for r in 0..rows { + for c in 0..cols { + let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); + let mm = m.then_before(Mat3::translate(off).then_before(ins.transform(blk.base.xy()))); + for be in blk.entities.iter() { + if *budget == 0 { + return; + } + *budget -= 1; + // Objects on layer 0 take the reference's layer, which is visible. + if !be.common.visible || (be.common.layer != "0" && !d.is_visible(be)) { + continue; + } + match &be.kind { + EntityKind::AttDef(_) => {} + EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, f), + k => f(k, &mm), + } + } + } + } +} + +/// Whether `p` comes within `aperture` of the box `probe`. +fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool { + let b = match p { + Prim::Seg(s) => s.bounds(), + Prim::Circle(c) => c.bounds(), + Prim::Ellipse(e) => e.bounds(), + Prim::Spline(s) => Bounds2::from_points(s.control.iter().copied()), + Prim::Point(q) => Bounds2::new(*q, *q), + Prim::Infinite { .. } => return true, + Prim::Fill(f) => Bounds2::from_points(f.iter().copied()), + }; + b.expand(aperture).intersects(probe) +} + +/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None` +/// when it distorts shapes (non-uniform scale or shear). +fn similarity_scale(m: &Mat3) -> Option { + let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d)); + let (lu, lv) = (u.len(), v.len()); + let tol = 1e-9 * lu.max(lv); + ((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu) +} + +/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale +/// become ellipses and elliptical arcs. +fn xform_prim(p: &Prim, m: &Mat3) -> Prim { + let mirror = m.is_mirroring(); + match p { + Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))), + Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) { + Some(s) => { + let c = m.apply(arc.center); + let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point()))); + let (start, end) = if mirror { (a1, a0) } else { (a0, a1) }; + Prim::Seg(Segment::Arc { arc: cadcraft_geom::Arc::new(c, arc.radius * s, start, end), ccw: *ccw != mirror }) + } + None => { + let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end }; + Prim::Ellipse(xform_ellipse(&e, m)) + } + }, + Prim::Circle(c) => match similarity_scale(m) { + Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)), + None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)), + }, + Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)), + Prim::Spline(s) => { + let mut s = s.clone(); + s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q)); + Prim::Spline(s) + } + Prim::Point(q) => Prim::Point(m.apply(*q)), + Prim::Infinite { base, dir, ray } => Prim::Infinite { base: m.apply(*base), dir: m.apply_vec(*dir).normalized(), ray: *ray }, + Prim::Fill(f) => Prim::Fill(f.iter().map(|q| m.apply(*q)).collect()), + } +} + +/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate +/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them +/// (and reverse under a mirroring transform). +fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse { + let center = m.apply(e.center); + let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor())); + // |am·cos t + an·sin t| is largest at t0: the new major axis. + let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an)); + let (s0, c0) = t0.sin_cos(); + let (u, v) = (am * c0 + an * s0, an * c0 - am * s0); + let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 }; + if e.is_full() { + return Ellipse::full(center, u, ratio); + } + let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) }; + Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) } +} + /// The nearest deferred tangent/perpendicular within `aperture` of `cursor`. fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32) -> Option { let mut best: Option<(f64, SnapHit)> = None; diff --git a/crates/engine/tests/snap_blocks.rs b/crates/engine/tests/snap_blocks.rs new file mode 100644 index 0000000..6d44893 --- /dev/null +++ b/crates/engine/tests/snap_blocks.rs @@ -0,0 +1,129 @@ +//! Object snaps find the geometry inside block references (#314). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, EntityKind, Insert, Space}; +use cadcraft_engine::geom::{Mat3, Vec2, Vec3}; +use cadcraft_engine::snap::{self, mode}; +use serde_json::json; + +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() +} + +/// Block "B": a line (0,0)-(10,0), a circle at (20,0) r2 and an arc at (0,10) r5 from 0° to 90°. +fn block_b(s: &mut Session) { + let l = handle(s, "line", json!({ "points": [[0, 0], [10, 0]] })); + let c = handle(s, "circle", json!({ "center": [20, 0], "radius": 2 })); + let a = handle(s, "arc", json!({ "center": [0, 10], "radius": 5, "start": 0, "end": 90 })); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l, c, a], "keep": "delete" })).unwrap(); +} + +/// Adds a reference of `block`; `grid` is (columns, rows, column spacing, row spacing). +fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: (f64, f64), rotation_deg: f64, grid: (u32, u32, f64, f64)) -> String { + let kind = EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(at.0, at.1, 0.0), + scale: Vec3::new(scale.0, scale.1, 1.0), + rotation: rotation_deg.to_radians(), + attribs: Vec::new(), + cols: grid.0, + rows: grid.1, + col_spacing: grid.2, + row_spacing: grid.3, + }); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() +} + +const ONE: (u32, u32, f64, f64) = (1, 1, 0.0, 0.0); + +/// The snap of `modes` near `p` (a hair off it, inside a 0.5 aperture). +fn snap_at(s: &Session, p: Vec2, modes: u32, base: Option) -> Option<(Vec2, u32)> { + snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.05, -0.04), 0.5, modes, base, false).map(|h| (h.point, h.mode)) +} + +fn assert_snaps(s: &Session, expect: Vec2, modes: u32) { + let (p, m) = snap_at(s, expect, modes, None).unwrap_or_else(|| panic!("no snap near {expect:?}")); + assert_eq!(m, modes, "snap mode near {expect:?}"); + assert!(p.near(expect, 1e-9), "snapped to {p:?}, expected {expect:?}"); +} + +#[test] +fn snaps_inside_rotated_scaled_and_mirrored_references() { + let mut s = Session::new(); + block_b(&mut s); + insert(&mut s, "B", (100.0, 100.0), (2.0, 2.0), 30.0, ONE); + let rot = 30f64.to_radians(); + let m = Mat3::translate(Vec2::new(100.0, 100.0)).then_before(Mat3::rotate(rot)).then_before(Mat3::scale(2.0, 2.0)); + let at = |x: f64, y: f64| m.apply(Vec2::new(x, y)); + assert_snaps(&s, at(10.0, 0.0), mode::END); + assert_snaps(&s, at(5.0, 0.0), mode::MID); + assert_snaps(&s, at(20.0, 0.0), mode::CEN); + // Quadrants stay on the world axes: the top of the circle (radius 4 once placed). + assert_snaps(&s, at(20.0, 0.0) + Vec2::new(0.0, 4.0), mode::QUA); + // The arc's start point. + assert_snaps(&s, at(5.0, 10.0), mode::END); + // Perpendicular from a point outside the block onto its line. + let (p, m2) = snap_at(&s, at(4.0, 0.0), mode::PER, Some(at(4.0, 3.0))).unwrap(); + assert!(m2 == mode::PER && p.near(at(4.0, 0.0), 1e-9), "{p:?}"); + // The insertion point still snaps. + assert_snaps(&s, Vec2::new(100.0, 100.0), mode::INS); + + // Mirrored in X and stretched 3x along Y: the circle becomes an ellipse. + let mut s = Session::new(); + block_b(&mut s); + insert(&mut s, "B", (0.0, 0.0), (-1.0, 3.0), 0.0, ONE); + assert_snaps(&s, Vec2::new(-10.0, 0.0), mode::END); + assert_snaps(&s, Vec2::new(-20.0, 0.0), mode::CEN); + for q in [Vec2::new(-20.0, 6.0), Vec2::new(-20.0, -6.0), Vec2::new(-18.0, 0.0), Vec2::new(-22.0, 0.0)] { + assert_snaps(&s, q, mode::QUA); + } + // The arc, now elliptical, ends where its ends were placed: (5,10) -> (-5,30), (0,15) -> (0,45). + assert_snaps(&s, Vec2::new(-5.0, 30.0), mode::END); + assert_snaps(&s, Vec2::new(0.0, 45.0), mode::END); + + // Mirrored in Y with a uniform scale of 2 the arc stays an arc. + let mut s = Session::new(); + block_b(&mut s); + insert(&mut s, "B", (0.0, 0.0), (2.0, -2.0), 0.0, ONE); + assert_snaps(&s, Vec2::new(10.0, -20.0), mode::END); + assert_snaps(&s, Vec2::new(0.0, -30.0), mode::END); + assert_snaps(&s, Vec2::new(0.0, -20.0), mode::CEN); + assert_snaps(&s, Vec2::new(0.0, -20.0) + Vec2::from_angle(-45f64.to_radians()) * 10.0, mode::MID); + assert_snaps(&s, Vec2::new(10.0, -20.0), mode::QUA); +} + +#[test] +fn snaps_inside_nested_and_arrayed_references_but_not_hidden_objects() { + let mut s = Session::new(); + block_b(&mut s); + // Block "N" holds a reference of B at (0,100); N is placed at (1000,0) as 3 columns 50 apart. + let inner = insert(&mut s, "B", (0.0, 100.0), (1.0, 1.0), 0.0, ONE); + s.execute("block", &json!({ "name": "N", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap(); + insert(&mut s, "N", (1000.0, 0.0), (1.0, 1.0), 0.0, (3, 1, 50.0, 0.0)); + assert_snaps(&s, Vec2::new(1010.0, 100.0), mode::END); + assert_snaps(&s, Vec2::new(1120.0, 100.0), mode::CEN); + // A loose line crossing the nested line of the second column. + s.execute("line", &json!({ "points": [[1055, 90], [1055, 110]] })).unwrap(); + assert_snaps(&s, Vec2::new(1055.0, 100.0), mode::INT); + + // A reference rotated 30° inside one mirrored and stretched 3x in Y: the arc becomes an + // elliptical arc whose axes don't follow the stretch; its ends still snap where they are. + let inner = insert(&mut s, "B", (0.0, 0.0), (1.0, 1.0), 30.0, ONE); + s.execute("block", &json!({ "name": "S", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap(); + insert(&mut s, "S", (0.0, 500.0), (1.0, -3.0), 0.0, ONE); + let m = Mat3::translate(Vec2::new(0.0, 500.0)).then_before(Mat3::scale(1.0, -3.0)).then_before(Mat3::rotate(30f64.to_radians())); + assert_snaps(&s, m.apply(Vec2::new(5.0, 10.0)), mode::END); + assert_snaps(&s, m.apply(Vec2::new(0.0, 15.0)), mode::END); + + // Objects on a layer that is off don't snap. + let mut s = Session::new(); + let line = EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }); + let common = Common { layer: "Hidden".into(), ..Common::default() }; + let l = s.doc_mut().unwrap().add(&Space::Model, common, line).unwrap().hex(); + s.execute("block", &json!({ "name": "H", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap(); + insert(&mut s, "H", (0.0, 0.0), (1.0, 1.0), 0.0, ONE); + assert_snaps(&s, Vec2::new(10.0, 0.0), mode::END); + s.doc_mut().unwrap().layer_mut("Hidden").unwrap().on = false; + assert_eq!(snap_at(&s, Vec2::new(10.0, 0.0), mode::END, None), None); +}