From e015fbe3286334f767587b1e2bc25a087b216bda Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 10 Jul 2026 13:32:32 +0300 Subject: [PATCH] fix(stretch): free crossing-window corners from Ortho, draw filled marquee (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRETCH picks its crossing window as two corner points. After the first corner set last_point, Ortho/Polar constrained the opposite corner to an axis through it, collapsing the window to a line and making selection impossible. Add CadCommand::window_corner_pick(); STRETCH returns true for its two window steps, and the viewport skips the ortho/polar (and OTRACK) lock for that point in both the preview and commit paths — the corners are now free. Also render the window like a normal box selection instead of a bare green outline: add SelectionState::preview_box (a hit-test-free marquee), draw it with the shared draw_marquee helper (green crossing fill + stroke), and drive it from the command's new window_first_corner(). Cleared on command end. Co-Authored-By: Claude Opus 4.8 --- src/app/update/viewport.rs | 48 +++++++++++++++++++++++++----- src/command.rs | 19 ++++++++++++ src/modules/draw/modify/stretch.rs | 36 +++++++++++----------- src/scene/pick/selection_state.rs | 6 ++++ src/scene/preview.rs | 3 ++ src/ui/overlay.rs | 43 +++++++++++--------------- 6 files changed, 105 insertions(+), 50 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e285d0457..38a80c745 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -909,6 +909,15 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .as_ref() .map(|c| c.is_selection_gathering()) .unwrap_or(false); + // A selection-window corner (STRETCH crossing window) is a + // free point — Ortho/Polar must not pin it to an axis through + // the first corner, or the rectangle collapses to a line + // (#291). + let is_window_corner = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.window_corner_pick()) + .unwrap_or(false); let needs_tan = self.tabs[i] .active_cmd .as_ref() @@ -956,7 +965,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { Instant::now(), ); if self.tabs[i].snap_result.is_none() { - let step = if self.polar_mode { + // A window corner is a free point, so neither the + // Polar increment nor the Ortho lock may pull the + // OTRACK alignment onto an axis (#291). + let step = if self.polar_mode && !is_window_corner { Some(self.polar_increment_deg) } else { None @@ -969,7 +981,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { bounds, step, self.last_point, - self.ortho_mode, + self.ortho_mode && !is_window_corner, ucs, ) } else { @@ -1018,7 +1030,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { let osnap_locked = self.tabs[i] .snap_result .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); - if !osnap_locked { + if !osnap_locked && !is_window_corner { if let Some(base) = self.last_point { let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { @@ -1075,6 +1087,20 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { self.tabs[i].last_point_screen = lps; self.tabs[i].dyn_ref_screen = drs; + // Point-picked selection window (STRETCH): draw a filled + // crossing marquee from the first corner to the cursor so it + // reads like a normal box selection, not a bare outline (#291). + let window_first = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|c| c.window_first_corner()); + self.tabs[i].scene.selection.borrow_mut().preview_box = + if is_window_corner { + window_first.map(|c1| (proj(c1.as_vec3()), p_full, true)) + } else { + None + }; + // Entity-pick previews (TRIM/EXTEND/FILLET…) compare the // cursor against WCS document entities and return WCS wires. // `effective` is offset-relative, so build a WCS copy for @@ -1812,6 +1838,13 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .as_ref() .map(|c| c.is_selection_gathering()) .unwrap_or(false); + // A committed window corner must stay free of the Ortho/Polar + // lock too, so the picked rectangle isn't flattened (#291). + let is_window_corner = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.window_corner_pick()) + .unwrap_or(false); if is_down && is_click && self.tabs[i].active_cmd.is_some() && !is_gathering { let (vw, vh) = (tile_vw, tile_vh); @@ -1876,14 +1909,14 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { // OTRACK alignment wins over ortho/polar; otherwise apply // ortho/polar relative to the last point. let otrack = if snap_hit.is_none() { - let step = if self.polar_mode { + let step = if self.polar_mode && !is_window_corner { Some(self.polar_increment_deg) } else { None }; let ucs = self.tabs[i].scene.viewcube_ucs_mat(); self.snapper - .otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, self.ortho_mode, ucs) + .otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, self.ortho_mode && !is_window_corner, ucs) } else { None }; @@ -1892,8 +1925,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { if self.tabs[i].active_ucs.is_none() { pt.z = 0.0; } - } else if !snap_hit - .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) + } else if !is_window_corner + && !snap_hit + .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) { // Object snap wins over ortho/polar — a snapped point // commits as-is. Grid snap still combines. (#132) diff --git a/src/command.rs b/src/command.rs index 0f0cab207..279559ac7 100644 --- a/src/command.rs +++ b/src/command.rs @@ -702,6 +702,25 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Returns `true` when the current step picks a corner of a selection + /// *window* by point (e.g. STRETCH's crossing window). Such a pick must be a + /// free point: applying the Ortho/Polar lock would pin the opposite corner to + /// an axis through the first corner, collapsing the rectangle to a line and + /// making the window unusable. The host skips the ortho/polar constraint for + /// these steps. Default `false`. (#291) + fn window_corner_pick(&self) -> bool { + false + } + + /// The already-picked first corner of the selection window (world space) + /// while `window_corner_pick()` is true and the opposite corner is being + /// dragged. The host projects it and draws a filled selection marquee to the + /// cursor, so a point-picked window (STRETCH) reads like a normal box + /// selection. `None` before the first corner is set. (#291) + fn window_first_corner(&self) -> Option { + None + } + /// Returns `true` when the command wants object picks via Tangent snap. fn needs_tangent_pick(&self) -> bool { false diff --git a/src/modules/draw/modify/stretch.rs b/src/modules/draw/modify/stretch.rs index 2e77cf240..a2a80a6b2 100644 --- a/src/modules/draw/modify/stretch.rs +++ b/src/modules/draw/modify/stretch.rs @@ -130,25 +130,27 @@ impl CadCommand for StretchCommand { CmdResult::Cancel } + fn window_corner_pick(&self) -> bool { + // The two crossing-window corners are free points; Ortho/Polar must not + // pin the opposite corner to an axis or the window becomes a line (#291). + matches!(self.step, Step::WindowCorner1 | Step::WindowCorner2(_)) + } + + fn window_first_corner(&self) -> Option { + // Expose the first corner so the host draws a filled crossing marquee to + // the cursor, matching a normal box selection instead of a bare outline. + match &self.step { + Step::WindowCorner2(c1) => Some(c1.as_dvec3()), + _ => None, + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let pt = pt.as_vec3(); match &self.step { - Step::WindowCorner2(c1) => { - // Show crossing-window rectangle preview (dashed green) - let c1 = *c1; - let pts = vec![ - [c1.x, c1.y, c1.z], - [pt.x, c1.y, c1.z], - [pt.x, pt.y, pt.z], - [c1.x, pt.y, pt.z], - [c1.x, c1.y, c1.z], - ]; - vec![WireModel::solid( - "stretch_window".into(), - pts, - [0.3, 1.0, 0.3, 0.7], - false, - )] - } + // The crossing-window rectangle is drawn as a filled selection + // marquee by the host (via window_first_corner) so it matches a + // normal box selection — nothing to draw here. (#291) + Step::WindowCorner2(_) => vec![], Step::Target { win_min, win_max, diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 3ed9124d2..aee7562d9 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -15,6 +15,12 @@ pub struct SelectionState { pub box_last: Option<(Point, Point)>, pub box_crossing: bool, pub box_last_crossing: bool, + /// A preview-only selection marquee `(anchor, current, crossing)` in canvas + /// pixels, drawn identically to a real box-selection (green crossing fill / + /// blue window fill) but with NO hit-test behaviour. Commands that pick a + /// window by point (STRETCH's crossing window) set this so the region reads + /// like a normal selection instead of a bare outline. (#291) + pub preview_box: Option<(Point, Point, bool)>, pub poly_active: bool, pub poly_points: Vec, pub poly_crossing: bool, diff --git a/src/scene/preview.rs b/src/scene/preview.rs index be2fb9f2c..679f8a3bc 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -27,6 +27,9 @@ impl Scene { self.preview_wires = vec![]; self.preview_text = vec![]; self.interim_wire = None; + // Drop any point-picked window marquee (STRETCH) so it doesn't linger + // after the command ends before the next mouse move. (#291) + self.selection.borrow_mut().preview_box = None; } pub fn wire_models_for(&self, handles: &[acadrust::Handle]) -> Vec { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index eedc793b0..43ecd34ec 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -430,36 +430,19 @@ impl canvas::Program for SelectionCanvas { } - if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) { - let (fill, stroke) = if self.selection.box_crossing { + // Draw a selection marquee (green crossing / blue window) as a filled, + // stroked rectangle between two canvas points. Shared by the live + // box-selection and the preview-only window marquee (#291). + fn draw_marquee(frame: &mut canvas::Frame, a: Point, b: Point, crossing: bool) { + let (fill, stroke) = if crossing { ( - Color { - r: 0.20, - g: 0.72, - b: 0.44, - a: 0.12, - }, - Color { - r: 0.20, - g: 0.72, - b: 0.44, - a: 0.9, - }, + Color { r: 0.20, g: 0.72, b: 0.44, a: 0.12 }, + Color { r: 0.20, g: 0.72, b: 0.44, a: 0.9 }, ) } else { ( - Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.12, - }, - Color { - r: 0.20, - g: 0.44, - b: 0.72, - a: 0.9, - }, + Color { r: 0.20, g: 0.44, b: 0.72, a: 0.12 }, + Color { r: 0.20, g: 0.44, b: 0.72, a: 0.9 }, ) }; let x0 = a.x.min(b.x); @@ -478,6 +461,14 @@ impl canvas::Program for SelectionCanvas { ); } + if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) { + draw_marquee(&mut frame, a, b, self.selection.box_crossing); + } + // Preview marquee for point-picked windows (STRETCH) — same look, no pick. + if let Some((a, b, crossing)) = self.selection.preview_box { + draw_marquee(&mut frame, a, b, crossing); + } + if self.selection.poly_active && self.selection.poly_points.len() > 1 { let (fill, stroke) = if self.selection.poly_crossing { (