From a181bbd609709bfa1bcadbcfcdc48638ee989634 Mon Sep 17 00:00:00 2001 From: PedroGuille Date: Sun, 11 Oct 2026 14:39:39 -0300 Subject: [PATCH] Fix Measure Distance snapping through layout viewports (#616) Squash of PR #616 by @PedroGuille. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/inquiry.rs | 39 ++- crates/engine/src/lib.rs | 13 + crates/engine/src/pointmod.rs | 2 +- crates/engine/src/snap.rs | 118 +++++-- crates/engine/src/snap/viewport.rs | 107 +++++++ .../engine/tests/measure_distance_snapping.rs | 296 ++++++++++++++++++ crates/ui-egui/src/canvas.rs | 9 +- .../tests/measure_distance_snapping.rs | 118 +++++++ 8 files changed, 672 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/snap/viewport.rs create mode 100644 crates/engine/tests/measure_distance_snapping.rs create mode 100644 crates/ui-egui/tests/measure_distance_snapping.rs diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 28b0473..f050092 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -347,8 +347,10 @@ mod count; #[derive(Default)] struct DistM { first: Option, + first_source: Option, /// Multiple points: the points so far, the first included. multi: Option>, + sources: Vec>, /// Asking for the length of a segment (the Length option). length: bool, } @@ -390,6 +392,12 @@ fn length_input(pts: &[Vec2], i: Input) -> Result> { 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]) -> Option> { + 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 { 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 { - 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) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 46941c2..3e46e21 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// Origin of the point currently being fed by `input_snapped`; never reused by typed points. + picked_viewport: Option, /// The last drawing comparison (COMPARE), drawn over the drawing it was made for. pub compare: Option, /// 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<()> { diff --git a/crates/engine/src/pointmod.rs b/crates/engine/src/pointmod.rs index 7fb1d00..74c1ab0 100644 --- a/crates/engine/src/pointmod.rs +++ b/crates/engine/src/pointmod.rs @@ -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))); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 6154f30..f39cb21 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -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, /// 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, deferred: bool, ) -> Option { - 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>, + cursor: Vec2, + aperture: f64, + osmode: u32, + base: Option, + deferred: bool, + frozen: &[String], + clip: Option, +) -> Option { + 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)> { + gather_in(d, space, ix, cursor, aperture, osmode, &[]) +} + +fn gather_in( + d: &Drawing, + space: &Space, + ix: &Option>, + cursor: Vec2, + aperture: f64, + osmode: u32, + frozen: &[String], ) -> Option<(Vec<(u32, Vec2)>, Vec)> { 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, deferred: bool, + clip: Option, ) -> Option { // 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 { /// 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 { +fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option) -> Option { 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) diff --git a/crates/engine/src/snap/viewport.rs b/crates/engine/src/snap/viewport.rs new file mode 100644 index 0000000..91a4568 --- /dev/null +++ b/crates/engine/src/snap/viewport.rs @@ -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 { + 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 { + 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> { + 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() +} diff --git a/crates/engine/tests/measure_distance_snapping.rs b/crates/engine/tests/measure_distance_snapping.rs new file mode 100644 index 0000000..adb3657 --- /dev/null +++ b/crates/engine/tests/measure_distance_snapping.rs @@ -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 = 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()); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0f0cfb0..43852f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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) { diff --git a/crates/ui-egui/tests/measure_distance_snapping.rs b/crates/ui-egui/tests/measure_distance_snapping.rs new file mode 100644 index 0000000..9f520b4 --- /dev/null +++ b/crates/ui-egui/tests/measure_distance_snapping.rs @@ -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); +}