From cda7381c3df4b70abf2a246420ec23d754badf7d Mon Sep 17 00:00:00 2001 From: Sorin Date: Sun, 18 Oct 2026 13:17:25 +0300 Subject: [PATCH] Keep rectangle opposite corners free of ortho (STRETCH window and friends) With ORTHOMODE on, the opposite corner of STRETCH's crossing window was bent onto an axis, so the window collapsed and caught nothing. The prompt set only the rubber-band base; it now marks it as a window corner (Prompt::corner), as ZOOM Window and RECTANG already do. Same fix for the other two-corner rectangles: the Window/Crossing/Box selection modes, PLOTWINDOW, MVIEW (and its Restore), -VIEW Window and REVCLOUD Rectangular. Regression tests in crates/engine/tests/stretch_window_ortho.rs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/draw2.rs | 4 +- crates/engine/src/cmd/layout/viewports.rs | 4 +- crates/engine/src/cmd/modify.rs | 2 +- crates/engine/src/cmd/plotters.rs | 2 +- crates/engine/src/cmd/view/named_views.rs | 2 +- crates/engine/src/select.rs | 2 +- crates/engine/tests/stretch_window_ortho.rs | 79 +++++++++++++++++++++ 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/stretch_window_ortho.rs diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index fca1c6611..24fe48636 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1057,7 +1057,9 @@ impl Interactive for RevcloudM { let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"]; match (self.kind, self.pts.len()) { (CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"), - (CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()), + (CloudKind::Rect, _) => { + Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) } + } (CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"), (CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), (CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), diff --git a/crates/engine/src/cmd/layout/viewports.rs b/crates/engine/src/cmd/layout/viewports.rs index 7f47c4e31..e82d881fc 100644 --- a/crates/engine/src/cmd/layout/viewports.rs +++ b/crates/engine/src/cmd/layout/viewports.rs @@ -637,13 +637,13 @@ impl Interactive for MviewM { Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW) .kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"]) .default("Right"), - Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), + Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a), Stage::Polygon(pi) => pi.prompt(), Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT), Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]), Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE), Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"), - Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), + Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a), Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT), } } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 37533b13c..3ffb110c9 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -486,7 +486,7 @@ impl Interactive for StretchM { return m.prompt(); } match (self.phase, self.corner) { - (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c), + (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c), (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT), (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"), (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"), diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 454b977ae..762f65066 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -365,7 +365,7 @@ impl Interactive for PlotWindowM { fn prompt(&self, _s: &Session) -> Prompt { match self.first { None => Prompt::new("Specify first corner", Accept::POINT), - Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { diff --git a/crates/engine/src/cmd/view/named_views.rs b/crates/engine/src/cmd/view/named_views.rs index 40c25632b..ef2750ef2 100644 --- a/crates/engine/src/cmd/view/named_views.rs +++ b/crates/engine/src/cmd/view/named_views.rs @@ -279,7 +279,7 @@ impl Interactive for ViewM { Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT), Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT), Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT), - Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]), Stage::Snapshot => { let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" }; diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 271e1631d..0f31837ed 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -419,7 +419,7 @@ impl SelectMode { }; match last { None => Prompt::new(first, Accept::POINT), - Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p), + Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p), Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p), } } diff --git a/crates/engine/tests/stretch_window_ortho.rs b/crates/engine/tests/stretch_window_ortho.rs new file mode 100644 index 000000000..70a7fdbfa --- /dev/null +++ b/crates/engine/tests/stretch_window_ortho.rs @@ -0,0 +1,79 @@ +//! STRETCH's crossing window ignores ortho: the opposite corner is where the cursor is, so the +//! window keeps its area and catches what it crosses. The same holds for every rectangle picked by +//! two corners (PLOTWINDOW, MVIEW, -VIEW Window, REVCLOUD Rectangular). + +use cadcraft_engine::snap::tracking::CursorQuery; +use cadcraft_engine::{Input, Session}; +use cadcraft_geom::Vec2; +use serde_json::json; + +/// Move the cursor to `raw` at the current prompt, as the canvas does. +fn hover(s: &mut Session, raw: Vec2) -> Vec2 { + let p = s.current_prompt().unwrap(); + let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }; + let c = s.snap_cursor(raw, &q).point; + s.cursor = c; + c +} + +/// A session with ortho on, object snaps off and a line from 0,0 to 10,0. +fn ortho_session() -> Session { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap(); + s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s +} + +/// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents. +fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) { + assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner"); + let c = hover(&mut s, to); + assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}"); + s.input(Input::Point(c)).unwrap(); + s.input(Input::Enter).unwrap(); + for t in ["0,0", "5,0"] { + s.cmdline(t).unwrap(); + } + assert!(s.current_prompt().is_none(), "STRETCH is done, still at {:?}", s.current_prompt().map(|p| p.message)); + let b = s.doc().unwrap().extents(&s.space()); + (b.min, b.max) +} + +#[test] +fn stretch_implied_window_ignores_ortho() { + let mut s = ortho_session(); + s.cmdline("stretch").unwrap(); + // Right to left: a crossing window. + s.input(Input::Point(Vec2::new(12.0, 1.0))).unwrap(); + assert_eq!(stretch_from_corner(s, Vec2::new(8.0, -1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5"); +} + +#[test] +fn stretch_crossing_mode_ignores_ortho() { + let mut s = ortho_session(); + for t in ["stretch", "c", "8,-1"] { + s.cmdline(t).unwrap(); + } + assert_eq!(stretch_from_corner(s, Vec2::new(12.0, 1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5"); +} + +#[test] +fn rectangle_corner_prompts_ignore_ortho() { + for script in [&["plotwindow", "0,0"][..], &["mview", "0,0"], &["-view", "w", "v1", "0,0"], &["revcloud", "r", "0,0"]] { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap(); + s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap(); + if script[0] == "mview" { + s.cmdline("layout s Layout1").unwrap(); + } + for t in script { + s.cmdline(t).unwrap(); + } + let p = s.current_prompt().unwrap(); + assert_eq!(p.message, "Specify opposite corner", "{script:?}"); + assert!(p.corner, "{script:?}: the opposite corner ignores ortho"); + let c = hover(&mut s, Vec2::new(10.0, 4.0)); + assert!(c.near(Vec2::new(10.0, 4.0), 1e-9), "{script:?}: the corner is where the cursor is: {c:?}"); + } +}