Fix Measure Distance snapping through layout viewports (#616)

Squash of PR #616 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
PedroGuilleandClaude Opus 5.5 authored and GitHub committed 2026-10-11 13:39:39 -04:00
1 parent ce1cc631d5
commit a181bbd609
8 files changed
+672 -30

No files matched your search

+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()
}
@@ -0,0 +1,296 @@
//! DIST snaps to the model geometry displayed in paper-space viewports (#516).
use cadcraft_engine::doc::{Block, Circle, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space, Viewport};
use cadcraft_engine::geom::{Vec2, Vec3};
use cadcraft_engine::snap::{SnapCurve, SnapHit, mode, osnap};
use cadcraft_engine::{Input, Session};
use serde_json::json;
fn drawing() -> (Drawing, Space) {
let mut d = Drawing::new_imperial();
let paper = Space::Paper("Layout1".into());
for (a, b) in [(Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0)), (Vec2::new(10.0, -10.0), Vec2::new(10.0, 10.0))] {
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) })).unwrap();
}
d.add(
&paper,
Common::default(),
EntityKind::Viewport(Viewport {
center: Vec3::new(100.0, 50.0, 0.0),
width: 20.0,
height: 20.0,
view_center: Vec2::new(10.0, 0.0),
view_height: 40.0,
id: 2,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}),
)
.unwrap();
(d, paper)
}
fn viewport(d: &Drawing, paper: &Space) -> Handle {
d.space(paper).unwrap().iter().find(|e| matches!(e.kind, EntityKind::Viewport(_))).unwrap().handle
}
fn edit_viewport(d: &mut Drawing, paper: &Space, edit: impl FnOnce(&mut Viewport)) {
d.modify_entity(viewport(d, paper), |e| match &mut e.kind {
EntityKind::Viewport(vp) => edit(vp),
_ => panic!("viewport fixture"),
})
.unwrap();
}
fn session(d: Drawing) -> Session {
let mut s = Session::empty();
s.open_drawing(d, "Drawing", None);
s.execute("layout.set", &json!({"name":"Layout1"})).unwrap();
s.execute("view.set", &json!({"center":[100,50],"height":30})).unwrap();
s.settings.osmode = mode::END | mode::INT;
s
}
fn pick(s: &Session, at: Vec2, modes: u32) -> SnapHit {
osnap(s.doc().unwrap(), &s.space(), at + Vec2::new(0.05, 0.04), 0.5, modes, None, false).expect("model snap through viewport")
}
#[test]
fn paper_space_snaps_to_visible_model_endpoints_and_intersections() {
let (d, paper) = drawing();
for (cursor, at, modes, expected) in [
(Vec2::new(95.1, 50.1), Vec2::new(95.0, 50.0), mode::END | mode::INT, mode::END),
(Vec2::new(100.1, 50.1), Vec2::new(100.0, 50.0), mode::INT, mode::INT),
] {
let hit = osnap(&d, &paper, cursor, 0.5, modes, None, false).expect("visible model geometry must snap from PAPER");
assert_eq!(hit.mode, expected);
assert!(hit.point.near(at, 1e-9));
let source = hit.viewport.unwrap();
assert_eq!(source.scale, 0.5);
assert!(source.point.near(Vec2::new((at.x - 100.0) * 2.0 + 10.0, (at.y - 50.0) * 2.0), 1e-9));
}
}
#[test]
fn model_space_running_snaps_and_disabled_snaps_keep_their_behavior() {
let (d, paper) = drawing();
let hit = osnap(&d, &Space::Model, Vec2::new(0.1, 0.1), 0.5, mode::END, None, false).unwrap();
assert_eq!(hit.point, Vec2::ZERO);
assert!(hit.viewport.is_none());
for modes in [0, mode::END | mode::INT | mode::OFF] {
assert!(osnap(&d, &paper, Vec2::new(95.1, 50.1), 0.5, modes, None, false).is_none());
}
}
#[test]
fn distance_uses_model_units_for_two_snaps_in_the_same_viewport() {
let (d, _) = drawing();
let mut s = session(d);
let before = s.state().unwrap().is_dirty();
s.start("dist").unwrap();
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
assert_eq!(s.current_prompt().unwrap().base, Some(Vec2::new(95.0, 50.0)), "preview remains in paper coordinates");
s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l.starts_with("Distance = 10.0000,")), "{:?}", s.log);
assert!(s.log.iter().any(|l| l.starts_with("Delta X = 10.0000, Delta Y = 0.0000")));
assert_eq!(s.state().unwrap().is_dirty(), before, "inquiry must not edit the drawing");
}
#[test]
fn typed_and_programmatic_points_are_measured_in_paper_units() {
let (d, _) = drawing();
let mut s = session(d);
for snapped_first in [true, false] {
s.start("dist").unwrap();
if snapped_first {
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
s.input(Input::Point(Vec2::new(100.0, 50.0))).unwrap();
} else {
s.input(Input::Point(Vec2::new(95.0, 50.0))).unwrap();
s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
}
assert!(s.log.iter().any(|l| l.starts_with("Distance = 5.0000,")), "{:?}", s.log);
s.log.clear();
}
assert_eq!(s.execute("dist", &json!({"p1":[95,50],"p2":[100,50]})).unwrap()["distance"], json!(5.0));
}
#[test]
fn points_from_different_viewports_use_paper_units() {
let (mut d, paper) = drawing();
let mut vp = match &d.entity(viewport(&d, &paper)).unwrap().kind {
EntityKind::Viewport(vp) => vp.clone(),
_ => panic!("viewport fixture"),
};
vp.center.x = 120.0;
vp.view_height = 20.0;
vp.id = 3;
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
let mut s = session(d);
s.start("dist").unwrap();
let first = pick(&s, Vec2::new(95.0, 50.0), mode::END);
let second = pick(&s, Vec2::new(110.0, 50.0), mode::END);
assert_ne!(first.viewport.unwrap().viewport, second.viewport.unwrap().viewport);
assert_eq!(first.viewport.unwrap().point, second.viewport.unwrap().point);
s.input_snapped(first).unwrap();
s.input_snapped(second).unwrap();
assert!(s.log.iter().any(|l| l.starts_with("Distance = 15.0000,")), "{:?}", s.log);
}
#[test]
fn multiple_points_length_and_undo_keep_the_measurement_units() {
let (d, _) = drawing();
let mut s = session(d);
s.start("dist").unwrap();
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
s.input(Input::Keyword("Multiple points".into())).unwrap();
s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
s.input_snapped(pick(&s, Vec2::new(105.0, 50.0), mode::END)).unwrap();
s.input(Input::Keyword("Length".into())).unwrap();
s.input(Input::Text("5".into())).unwrap();
assert_eq!(s.log.last().unwrap(), "Distance = 25.0000");
assert_eq!(s.current_prompt().unwrap().base, Some(Vec2::new(107.5, 50.0)));
s.input(Input::Keyword("Undo".into())).unwrap();
s.input(Input::Point(Vec2::new(102.0, 52.0))).unwrap();
s.input(Input::Keyword("Undo".into())).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(s.log.last().unwrap(), "Distance = 20.0000");
}
#[test]
fn explicit_endpoint_and_intersection_overrides_work_from_the_command_line() {
let (d, _) = drawing();
let mut s = session(d);
s.settings.osmode = mode::OFF;
s.cmdline("dist").unwrap();
s.cmdline("end").unwrap();
s.cmdline("95.05,50.04").unwrap();
s.cmdline("int").unwrap();
s.cmdline("100.05,50.04").unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l.starts_with("Distance = 10.0000,")), "{:?}", s.log);
}
#[test]
fn clipped_endpoints_do_not_hide_a_visible_candidate() {
let (mut d, paper) = drawing();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(30.2, 2.0, 0.0), b: Vec3::new(29.0, 2.0, 0.0) })).unwrap();
let hit = osnap(&d, &paper, Vec2::new(110.11, 51.0), 0.8, mode::END, None, false).unwrap();
assert_eq!(hit.point, Vec2::new(109.5, 51.0), "only the endpoint inside the viewport is visible");
assert!(osnap(&d, &paper, Vec2::new(110.11, 51.0), 0.2, mode::END, None, false).is_none());
}
#[test]
fn viewport_frozen_layers_do_not_hide_paper_space_geometry() {
let (mut d, paper) = drawing();
edit_viewport(&mut d, &paper, |vp| vp.frozen_layers.push("0".into()));
assert!(osnap(&d, &paper, Vec2::new(95.1, 50.1), 0.5, mode::END, None, false).is_none());
d.add(&paper, Common::default(), EntityKind::Line(Line { a: Vec3::new(99.0, 51.0, 0.0), b: Vec3::new(101.0, 51.0, 0.0) })).unwrap();
let hit = osnap(&d, &paper, Vec2::new(99.1, 51.1), 0.5, mode::END, None, false).unwrap();
assert_eq!(hit.point, Vec2::new(99.0, 51.0));
assert!(hit.viewport.is_none());
}
#[test]
fn viewport_frozen_layers_apply_inside_nested_block_references() {
let (mut d, paper) = drawing();
d.layers.push(cadcraft_engine::doc::Layer { name: "Detail".into(), ..Default::default() });
let mut inner = Block::new("Inner");
inner.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::new(0.0, 4.0, 0.0), b: Vec3::new(2.0, 4.0, 0.0) }),
});
d.blocks.insert(inner.name.clone(), std::sync::Arc::new(inner));
let insert = |block: &str| {
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: 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,
})
};
let mut outer = Block::new("Outer");
outer.entities.push(Entity { handle: d.new_handle(), common: Common { layer: "Detail".into(), ..Default::default() }, kind: insert("Inner") });
d.blocks.insert(outer.name.clone(), std::sync::Arc::new(outer));
d.add(&Space::Model, Common::default(), insert("Outer")).unwrap();
let cursor = Vec2::new(95.05, 52.04);
assert_eq!(osnap(&d, &paper, cursor, 0.5, mode::END, None, false).unwrap().point, Vec2::new(95.0, 52.0));
edit_viewport(&mut d, &paper, |vp| vp.frozen_layers.push("detail".into()));
assert!(osnap(&d, &paper, cursor, 0.5, mode::END, None, false).is_none());
assert_eq!(osnap(&d, &Space::Model, Vec2::new(0.1, 4.1), 0.5, mode::END, None, false).unwrap().point, Vec2::new(0.0, 4.0));
}
#[test]
fn clipped_deferred_curves_do_not_hide_a_visible_candidate() {
let (mut d, paper) = drawing();
// Hide the viewport border so its perpendicular snap does not win this query.
d.layers.push(cadcraft_engine::doc::Layer { name: "Viewports".into(), on: false, ..Default::default() });
d.modify_entity(viewport(&d, &paper), |e| e.common.layer = "Viewports".into()).unwrap();
for x in [30.2, 29.0] {
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(x, 1.0, 0.0), b: Vec3::new(x, 3.0, 0.0) })).unwrap();
}
let hit = osnap(&d, &paper, Vec2::new(110.11, 51.0), 0.8, mode::PER, None, true).unwrap();
assert_eq!(hit.point, Vec2::new(109.5, 51.0));
assert!(hit.deferred.is_some());
assert!(hit.viewport.is_some());
}
#[test]
fn off_viewport_borders_keep_contents_but_frozen_or_hidden_viewports_do_not() {
let (mut d, paper) = drawing();
d.layer_mut("0").unwrap().on = false;
// Put model geometry on a different, visible layer.
d.layers.push(cadcraft_engine::doc::Layer { name: "Geometry".into(), ..Default::default() });
let handles: Vec<Handle> = d.model.iter().map(|e| e.handle).collect();
for h in handles {
d.modify_entity(h, |e| e.common.layer = "Geometry".into()).unwrap();
}
assert!(osnap(&d, &paper, Vec2::new(95.1, 50.1), 0.5, mode::END, None, false).is_some());
d.layer_mut("0").unwrap().frozen = true;
assert!(osnap(&d, &paper, Vec2::new(95.1, 50.1), 0.5, mode::END, None, false).is_none());
d.layer_mut("0").unwrap().frozen = false;
let h = d.space(&paper).unwrap().iter().find(|e| matches!(e.kind, EntityKind::Viewport(_))).unwrap().handle;
d.modify_entity(h, |e| e.common.visible = false).unwrap();
assert!(osnap(&d, &paper, Vec2::new(95.1, 50.1), 0.5, mode::END, None, false).is_none());
}
#[test]
fn deferred_curves_are_projected_into_paper_coordinates() {
let (mut d, paper) = drawing();
d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(10.0, 4.0, 0.0), radius: 2.0 })).unwrap();
let hit = osnap(&d, &paper, Vec2::new(101.1, 52.0), 0.5, mode::TAN, None, true).unwrap();
assert!(hit.point.near(Vec2::new(101.0, 52.0), 1e-9));
match hit.deferred.unwrap().curve {
SnapCurve::Circle { center, radius } => {
assert_eq!(center, Vec2::new(100.0, 52.0));
assert_eq!(radius, 1.0);
}
_ => panic!("circle tangent"),
}
}
#[test]
fn malformed_and_paper_space_viewports_are_not_snap_sources() {
for (width, height, view_height, id) in [
(20.0, 20.0, 40.0, 1),
(-20.0, 20.0, 40.0, 2),
(20.0, 20.0, 0.0, 2),
(f64::NAN, 20.0, 40.0, 2),
(20.0, f64::INFINITY, 40.0, 2),
(20.0, 1e308, 1e-308, 2),
] {
let (mut d, paper) = drawing();
edit_viewport(&mut d, &paper, |vp| (vp.width, vp.height, vp.view_height, vp.id) = (width, height, view_height, id));
assert!(osnap(&d, &paper, Vec2::new(95.1, 50.1), 0.5, mode::END, None, false).is_none());
}
}
+6 -3
View File
@@ -774,9 +774,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
{
app.cmd.buffer.clear();
}
// A deferred tangent/perpendicular goes to the command as such.
let input = app.canvas.snap.filter(|h| h.deferred.is_some()).map_or(Input::Point(p), |h| h.input());
if let Err(e) = app.session.input(input) {
// Preserve a snap's viewport origin for DIST, and its deferred curve where needed.
let result = match app.canvas.snap {
Some(hit) => app.session.input_snapped(hit),
None => app.session.input(Input::Point(p)),
};
if let Err(e) = result {
app.session.echo(e.to_string());
}
} else if let Some(g) = grip_at(app, &xf, hover_pos) {
@@ -0,0 +1,118 @@
//! Measure Distance uses real pointer snaps in MODEL and PAPER, including viewport scale (#516).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Line, Space, Viewport};
use cadcraft_engine::geom::{Vec2, Vec3};
use cadcraft_engine::snap::mode;
use cadcraft_ui_egui::{CadApp, Services};
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(paper: bool, render: bool) -> Harness<'static, CadApp> {
let mut d = Drawing::new_imperial();
for (a, b) in [(Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0)), (Vec2::new(10.0, -10.0), Vec2::new(10.0, 10.0))] {
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) })).unwrap();
}
d.add(
&Space::Paper("Layout1".into()),
Common::default(),
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(10.0, 0.0),
view_height: 40.0,
id: 2,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}),
)
.unwrap();
let mut session = Session::empty();
session.open_drawing(d, "Measure snap regression", None);
if paper {
session.execute("layout.set", &json!({"name":"Layout1"})).unwrap();
}
session.execute("view.set", &if paper { json!({"center":[5,4],"height":10}) } else { json!({"center":[10,0],"height":30}) }).unwrap();
session.settings.osmode = mode::END | mode::INT;
session.settings.snapmode = true;
session.settings.orthomode = true;
let mut app = CadApp::new(session, Services::default());
app.canvas.zoom_pending = false;
let builder = Harness::builder().with_size(vec2(1600.0, 1000.0));
let builder = if render { builder.wgpu() } else { builder };
let mut h = builder.build_ui_state(
|ui, app: &mut CadApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(5);
h
}
fn hover(h: &mut Harness<'_, CadApp>, point: Vec2) -> Pos2 {
let pos = h.state().canvas.xf.unwrap().to_screen(point) + vec2(3.0, 2.0);
h.event(Event::PointerMoved(pos));
h.run_steps(3);
pos
}
fn click(h: &mut Harness<'_, CadApp>, pos: Pos2) {
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
fn measure(paper: bool) {
let shots = std::env::var_os("CADCRAFT_MEASURE_SHOTS").map(std::path::PathBuf::from);
let mut h = harness(paper, shots.is_some());
let dirty_before = h.state().session.state().unwrap().is_dirty();
h.get_by_role_and_label(egui::accesskit::Role::Button, "Measure Distance").click();
h.run_steps(3);
assert_eq!(h.state().session.running.as_ref().unwrap().id, "dist");
let (endpoint, intersection) = if paper { (Vec2::new(3.5, 4.0), Vec2::new(5.0, 4.0)) } else { (Vec2::ZERO, Vec2::new(10.0, 0.0)) };
let pos = hover(&mut h, endpoint);
let hit = h.state().canvas.snap.expect("endpoint marker");
assert_eq!(hit.mode, mode::END);
assert!(hit.point.near(endpoint, 1e-6));
assert_eq!(hit.viewport.is_some(), paper);
if let Some(path) = &shots {
std::fs::create_dir_all(path).unwrap();
h.render().unwrap().save(path.join(if paper { "paper-endpoint.png" } else { "model-endpoint.png" })).unwrap();
}
click(&mut h, pos);
assert!(h.state().session.current_prompt().unwrap().base.unwrap().near(endpoint, 1e-6));
let pos = hover(&mut h, intersection);
let hit = h.state().canvas.snap.expect("intersection marker");
assert_eq!(hit.mode, mode::INT);
assert!(hit.point.near(intersection, 1e-6));
if let Some(path) = &shots {
h.render().unwrap().save(path.join(if paper { "paper-intersection.png" } else { "model-intersection.png" })).unwrap();
}
click(&mut h, pos);
let app = h.state();
assert!(app.session.running.is_none());
assert!(app.session.log.iter().any(|l| l.starts_with("Distance = 10.0000,")), "{:?}", app.session.log);
assert_eq!(app.session.state().unwrap().is_dirty(), dirty_before);
if let Some(path) = &shots {
h.render().unwrap().save(path.join(if paper { "paper-distance.png" } else { "model-distance.png" })).unwrap();
}
}
#[test]
fn measure_distance_toolbar_snaps_to_model_geometry_through_paper_viewports() {
measure(true);
}
#[test]
fn measure_distance_toolbar_keeps_model_space_snaps_working() {
measure(false);
}