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 fca1c661..24fe4863 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 7f47c4e3..e82d881f 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 37533b13..3ffb110c 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 454b977a..762f6506 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 40c25632..ef2750ef 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 271e1631..0f31837e 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 00000000..70a7fdbf --- /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:?}"); + } +}