Fix Measure Distance snapping through layout viewports (#616)

Squash of PR #616 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PedroGuille
2026-10-11 13:39:39 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ce1cc631d5
commit a181bbd609
8 changed files with 672 additions and 30 deletions
+34 -5
View File
@@ -347,8 +347,10 @@ mod count;
#[derive(Default)]
struct DistM {
first: Option<Vec2>,
first_source: Option<crate::snap::ViewportPoint>,
/// Multiple points: the points so far, the first included.
multi: Option<Vec<Vec2>>,
sources: Vec<Option<crate::snap::ViewportPoint>>,
/// Asking for the length of a segment (the Length option).
length: bool,
}
@@ -390,6 +392,12 @@ fn length_input(pts: &[Vec2], i: Input) -> Result<Option<Vec2>> {
Ok(extend_last(pts, len))
}
/// Model units apply only when every point was snapped through one and the same viewport.
fn model_points(sources: &[Option<crate::snap::ViewportPoint>]) -> Option<Vec<Vec2>> {
let viewport = sources.first().copied().flatten()?.viewport;
sources.iter().map(|s| s.filter(|s| s.viewport == viewport).map(|s| s.point)).collect()
}
impl Interactive for DistM {
fn name(&self) -> &'static str {
"DIST"
@@ -413,9 +421,20 @@ impl Interactive for DistM {
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(pts) = &mut self.multi {
if self.length {
if let Some(p) = length_input(pts, i)? {
pts.push(p);
s.echo(format!("Distance = {}", fmt_d(s, path_len(pts))));
let model = model_points(&self.sources);
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? {
let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten());
let paper = match (source, pts.last()) {
(Some(source), Some(last)) => *last + (p - source.point) * source.scale,
_ => p,
};
if !paper.is_finite() {
return Err(bad("dist", "the next point is too large"));
}
pts.push(paper);
self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source }));
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
}
self.length = false;
return Ok(Step::Continue);
@@ -423,11 +442,14 @@ impl Interactive for DistM {
match i {
Input::Point(p) => {
pts.push(p);
s.echo(format!("Distance = {}", fmt_d(s, path_len(pts))));
self.sources.push(s.picked_viewport);
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
}
Input::Keyword(k) if k == "Undo" => {
if pts.len() > 1 {
pts.pop();
self.sources.pop();
}
}
Input::Keyword(k) if k == "Length" => self.length = true,
@@ -441,9 +463,14 @@ impl Interactive for DistM {
match (self.first, i) {
(None, Input::Point(p)) => {
self.first = Some(p);
self.first_source = s.picked_viewport;
Ok(Step::Continue)
}
(Some(a), Input::Point(b)) => {
let (a, b) = match (self.first_source, s.picked_viewport) {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
};
let t = dist_text(s, a, b);
for l in t.lines() {
s.echo(l.to_string());
@@ -452,6 +479,7 @@ impl Interactive for DistM {
}
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
self.multi = Some(vec![a]);
self.sources = vec![self.first_source];
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Done),
@@ -472,7 +500,8 @@ impl Interactive for DistM {
impl DistM {
fn total(&self, s: &mut Session) -> Result<Step> {
let d = self.multi.as_deref().map_or(0.0, path_len);
let model = model_points(&self.sources);
let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len);
s.echo(format!("Distance = {}", fmt_d(s, d)));
Ok(Step::Done)
}
+13
View File
@@ -556,6 +556,8 @@ pub struct Session {
/// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the
/// rubber band show the line it resolves to).
pub cursor_deferred: Option<snap::Deferred>,
/// Origin of the point currently being fed by `input_snapped`; never reused by typed points.
picked_viewport: Option<snap::ViewportPoint>,
/// The last drawing comparison (COMPARE), drawn over the drawing it was made for.
pub compare: Option<cmd::file::compare::Comparison>,
/// Object snap tracking: acquired points and the path under the cursor (from the UI's
@@ -606,6 +608,7 @@ impl Session {
point_mods: Vec::new(),
cursor: Vec2::ZERO,
cursor_deferred: None,
picked_viewport: None,
compare: None,
tracking: snap::tracking::Tracker::default(),
viewport_px: (1200.0, 800.0),
@@ -841,6 +844,16 @@ impl Session {
}
}
/// Feed an object-snap pick, retaining its viewport origin for measurements. Point modifiers
/// that combine or constrain coordinates produce ordinary drawing-space points.
pub fn input_snapped(&mut self, hit: snap::SnapHit) -> Result<()> {
let source = hit.viewport.filter(|_| self.point_mods.iter().all(|f| matches!(f, pointmod::Frame::Snap { mode } if *mode != 0)));
let previous = std::mem::replace(&mut self.picked_viewport, source);
let result = self.input(hit.input());
self.picked_viewport = previous;
result
}
/// Give an input to the running command. With no command running, `Enter` repeats the last
/// command (as in AutoCAD) and points do nothing.
pub fn input(&mut self, input: Input) -> Result<()> {
+1 -1
View File
@@ -349,7 +349,7 @@ impl Session {
let deferred = prompt.as_ref().is_some_and(|q| q.deferred);
let hit = crate::snap::osnap(self.doc()?, &space, p, aperture, mode | hatch, base, deferred);
match hit {
Some(SnapHit { deferred: Some(d), .. }) if self.point_mods.is_empty() => return self.input(Input::Deferred(d)),
Some(h) if self.point_mods.is_empty() => return self.input_snapped(h),
Some(h) => p = h.point,
None => {
self.echo(format!("No {} found for specified point.", snap_name(mode)));
+97 -21
View File
@@ -3,6 +3,9 @@
mod curves;
pub mod iso;
pub mod tracking;
mod viewport;
pub use viewport::ViewportPoint;
use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space};
use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
@@ -52,6 +55,9 @@ pub struct SnapHit {
pub point: Vec2,
pub mode: u32,
pub name: &'static str,
/// Model coordinates when this paper-space hit is inside a layout viewport.
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<ViewportPoint>,
/// A deferred tangent/perpendicular: `point` is only provisional (the nearest point on the
/// curve) until the other end of the line is known. See [`resolve_deferred`].
#[serde(skip_serializing_if = "Option::is_none")]
@@ -148,10 +154,59 @@ pub(crate) fn osnap_with(
base: Option<Vec2>,
deferred: bool,
) -> Option<SnapHit> {
if osmode == 0 || osmode & mode::OFF != 0 {
if osmode == 0 || osmode & mode::OFF != 0 || !cursor.is_finite() || !(aperture.is_finite() && aperture > 0.0) {
return None;
}
let (mut cands, near_prims) = gather(d, space, ix, cursor, aperture, osmode)?;
let mut best = osnap_in(d, space, ix, cursor, aperture, osmode, base, deferred, &[], None);
let views = viewport::near(d, space, cursor, aperture);
if views.is_empty() {
return best;
}
let model = Space::Model;
let model_ix = crate::spatial::index(d, &model);
for view in views {
let model_cursor = view.to_model(cursor);
let model_aperture = aperture / view.scale;
let model_base = base.map(|p| view.to_model(p));
if !model_cursor.is_finite() || !(model_aperture.is_finite() && model_aperture > 0.0) || model_base.is_some_and(|p| !p.is_finite()) {
continue;
}
let hit = osnap_in(
d,
&model,
&model_ix,
model_cursor,
model_aperture,
osmode,
model_base,
deferred,
&view.viewport.frozen_layers,
Some(view.model_clip),
)
.and_then(|h| view.hit(h));
if let Some(hit) = hit
&& best.as_ref().is_none_or(|b| snap_score(hit.mode, hit.point, cursor, aperture) < snap_score(b.mode, b.point, cursor, aperture))
{
best = Some(hit);
}
}
best
}
#[allow(clippy::too_many_arguments)]
fn osnap_in(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
cursor: Vec2,
aperture: f64,
osmode: u32,
base: Option<Vec2>,
deferred: bool,
frozen: &[String],
clip: Option<Bounds2>,
) -> Option<SnapHit> {
let (mut cands, near_prims) = gather_in(d, space, ix, cursor, aperture, osmode, frozen)?;
// Intersections between nearby primitives; Apparent Intersection also where their extensions
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
// that don't reach each other).
@@ -181,7 +236,7 @@ pub(crate) fn osnap_with(
}
}
}
finish(cands, &near_prims, cursor, aperture, osmode, base, deferred)
finish(cands, &near_prims, cursor, aperture, osmode, base, deferred, clip)
}
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
@@ -198,6 +253,18 @@ pub(crate) fn gather(
cursor: Vec2,
aperture: f64,
osmode: u32,
) -> Option<(Vec<(u32, Vec2)>, Vec<Prim>)> {
gather_in(d, space, ix, cursor, aperture, osmode, &[])
}
fn gather_in(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
cursor: Vec2,
aperture: f64,
osmode: u32,
frozen: &[String],
) -> Option<(Vec<(u32, Vec2)>, Vec<Prim>)> {
let probe = Bounds2::new(cursor, cursor).expand(aperture);
let mut cands: Vec<(u32, Vec2)> = Vec::new();
@@ -205,7 +272,7 @@ pub(crate) fn gather(
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) {
if !d.is_visible(e) || frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer)) {
continue;
}
let inf = crate::select::is_infinite(e);
@@ -238,16 +305,17 @@ pub(crate) fn gather(
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)));
block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, frozen, &mut |k, m| sink.object(k, Some(m)));
}
if let EntityKind::Dimension(dm) = &e.kind {
sink.dimension(d, dm, &mut budget);
sink.dimension(d, dm, &mut budget, frozen);
}
}
Some((cands, near_prims))
}
/// Picks the best of the gathered candidates, adding the base-dependent and deferred snaps.
#[allow(clippy::too_many_arguments)]
fn finish(
mut cands: Vec<(u32, Vec2)>,
near_prims: &[Prim],
@@ -256,6 +324,7 @@ fn finish(
osmode: u32,
base: Option<Vec2>,
deferred: bool,
clip: Option<Bounds2>,
) -> Option<SnapHit> {
// Perpendicular / tangent from the base point.
if let Some(bp) = base {
@@ -283,18 +352,19 @@ fn finish(
}
}
}
let best = cands.iter().filter(|(_, p)| p.dist(cursor) <= aperture).min_by(|a, b| {
let visible = |p: Vec2| p.is_finite() && clip.is_none_or(|c| c.contains(p));
let best = cands.iter().filter(|(_, p)| visible(*p) && p.dist(cursor) <= aperture).min_by(|a, b| {
// Prefer more specific modes when nearly equidistant.
let da = a.1.dist(cursor) - if a.0 == mode::INT || a.0 == mode::END { aperture * 0.15 } else { 0.0 };
let db = b.1.dist(cursor) - if b.0 == mode::INT || b.0 == mode::END { aperture * 0.15 } else { 0.0 };
let da = snap_score(a.0, a.1, cursor, aperture);
let db = snap_score(b.0, b.1, cursor, aperture);
da.total_cmp(&db)
});
if let Some((m, p)) = best {
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), deferred: None });
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), viewport: None, deferred: None });
}
if base.is_none()
&& deferred
&& let Some(h) = deferred_hit(near_prims, cursor, aperture, osmode)
&& let Some(h) = deferred_hit(near_prims, cursor, aperture, osmode, clip)
{
return Some(h);
}
@@ -303,7 +373,7 @@ fn finish(
let mut nb: Option<(f64, Vec2)> = None;
let mut consider = |c: Vec2| {
let dd = c.dist(cursor);
if dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) {
if visible(c) && dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) {
nb = Some((dd, c));
}
};
@@ -316,12 +386,16 @@ fn finish(
}
}
if let Some((_, p)) = nb {
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", deferred: None });
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", viewport: None, deferred: None });
}
}
None
}
fn snap_score(mode: u32, point: Vec2, cursor: Vec2, aperture: f64) -> f64 {
point.dist(cursor) - if mode == mode::INT || mode == mode::END { aperture * 0.15 } else { 0.0 }
}
/// Upper bound on the block-reference objects one snap query looks at (hostile files).
const MAX_BLOCK_LEAVES: usize = 20_000;
@@ -463,7 +537,7 @@ 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) {
fn dimension(&mut self, d: &Drawing, dm: &cadcraft_doc::Dimension, budget: &mut usize, frozen: &[String]) {
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.
@@ -478,7 +552,7 @@ impl Sink<'_> {
col_spacing: 0.0,
row_spacing: 0.0,
};
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, &mut |k, m| self.object(k, Some(m)));
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, frozen, &mut |k, m| self.object(k, Some(m)));
}
None => {
let g = cadcraft_render::dimension_in(d, dm);
@@ -523,7 +597,7 @@ fn dim_def_points(dm: &cadcraft_doc::Dimension) -> Vec<Vec2> {
/// 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)) {
fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut usize, frozen: &[String], f: &mut dyn FnMut(&EntityKind, &Mat3)) {
if depth >= MAX_BLOCK_DEPTH {
return;
}
@@ -547,12 +621,14 @@ fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut
}
*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)) {
if !be.common.visible
|| (be.common.layer != "0" && (!d.is_visible(be) || frozen.iter().any(|l| l.eq_ignore_ascii_case(&be.common.layer))))
{
continue;
}
match &be.kind {
EntityKind::AttDef(_) => {}
EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, f),
EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, frozen, f),
k => f(k, &mm),
}
}
@@ -636,7 +712,7 @@ fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse {
}
/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`.
fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32) -> Option<SnapHit> {
fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option<Bounds2>) -> Option<SnapHit> {
let mut best: Option<(f64, SnapHit)> = None;
for p in prims {
let curve = match p {
@@ -661,8 +737,8 @@ fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32) -> Opt
_ => continue,
};
let dd = at.dist(cursor);
if at.is_finite() && dd <= aperture && best.is_none_or(|(bd, _)| dd < bd) {
best = Some((dd, SnapHit { point: at, mode: m, name, deferred: Some(Deferred { mode: m, curve, at }) }));
if at.is_finite() && clip.is_none_or(|c| c.contains(at)) && dd <= aperture && best.is_none_or(|(bd, _)| dd < bd) {
best = Some((dd, SnapHit { point: at, mode: m, name, viewport: None, deferred: Some(Deferred { mode: m, curve, at }) }));
}
}
best.map(|(_, h)| h)
+107
View File
@@ -0,0 +1,107 @@
//! Snap queries through the same rectangular layout viewports that the renderer displays.
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport};
use cadcraft_geom::{Bounds2, Vec2};
use serde::Serialize;
use super::{Deferred, SnapCurve, SnapHit};
/// A model-space snap seen through a layout viewport. Commands still receive paper coordinates;
/// DIST can use these model coordinates when both picks came through the same viewport.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ViewportPoint {
pub viewport: Handle,
pub point: Vec2,
/// Paper units per model unit at the time of the pick.
pub scale: f64,
}
pub(super) struct Projection<'a> {
pub handle: Handle,
pub viewport: &'a Viewport,
pub scale: f64,
pub model_clip: Bounds2,
paper_clip: Bounds2,
}
impl<'a> Projection<'a> {
fn new(handle: Handle, viewport: &'a Viewport) -> Option<Self> {
let positive = |v: f64| v.is_finite() && v > 0.0;
let center = viewport.center.xy();
if viewport.id == 1
|| !(center.is_finite() && viewport.view_center.is_finite())
|| ![viewport.width, viewport.height, viewport.view_height].into_iter().all(positive)
{
return None;
}
let scale = viewport.height / viewport.view_height;
if !positive(scale) || !positive(1.0 / scale) {
return None;
}
let half = Vec2::new(viewport.width / 2.0, viewport.height / 2.0);
let paper_clip = Bounds2::new(center - half, center + half);
let model_clip = Bounds2::new(viewport.view_center - half / scale, viewport.view_center + half / scale);
if ![paper_clip.min, paper_clip.max, model_clip.min, model_clip.max].into_iter().all(Vec2::is_finite) {
return None;
}
Some(Self { handle, viewport, scale, model_clip, paper_clip })
}
pub fn to_model(&self, point: Vec2) -> Vec2 {
self.viewport.view_center + (point - self.viewport.center.xy()) / self.scale
}
fn to_paper(&self, point: Vec2) -> Vec2 {
self.viewport.center.xy() + (point - self.viewport.view_center) * self.scale
}
pub fn hit(&self, mut hit: SnapHit) -> Option<SnapHit> {
let model_point = hit.point;
hit.point = self.to_paper(model_point);
if !hit.point.is_finite() || !self.paper_clip.contains(hit.point) {
return None;
}
hit.viewport = Some(ViewportPoint { viewport: self.handle, point: model_point, scale: self.scale });
if let Some(d) = hit.deferred {
let curve = match d.curve {
SnapCurve::Circle { center, radius } => {
let (center, radius) = (self.to_paper(center), radius * self.scale);
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
return None;
}
SnapCurve::Circle { center, radius }
}
SnapCurve::Line { a, b } => {
let (a, b) = (self.to_paper(a), self.to_paper(b));
if !(a.is_finite() && b.is_finite()) {
return None;
}
SnapCurve::Line { a, b }
}
};
hit.deferred = Some(Deferred { mode: d.mode, curve, at: hit.point });
}
Some(hit)
}
}
/// Bound layout work like the renderer. An off viewport layer hides its border, while a frozen
/// viewport layer (or an invisible viewport entity) also hides its model contents.
pub(super) fn near<'a>(d: &'a Drawing, space: &Space, cursor: Vec2, aperture: f64) -> Vec<Projection<'a>> {
if !matches!(space, Space::Paper(_)) {
return Vec::new();
}
let Some(store) = d.space(space) else { return Vec::new() };
let probe = Bounds2::new(cursor, cursor).expand(aperture);
store
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Viewport(vp) => Some((e, vp)),
_ => None,
})
.take(256)
.filter(|(e, _)| e.common.visible && !d.layer(&e.common.layer).is_some_and(|l| l.frozen))
.filter_map(|(e, vp)| Projection::new(e.handle, vp))
.filter(|p| p.paper_clip.intersects(&probe))
.collect()
}