From eb513ac5f800ece122d055b1342acdd1c4491f0c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 00:42:16 +0300 Subject: [PATCH] fix(stretch): let the selection stage behave like every other one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRETCH ended itself when a crossing window caught nothing, so a missed aim meant restarting the command to try again. A missed aim is not a decision to stop: say what happened and ask for the corner afresh, the way a selection that picks nothing leaves MOVE still asking. Enter now ends the selection stage too. With objects already picked the window is only there to narrow them, so there is nothing left to choose — Enter takes a window containing all of them, which stretches every vertex, which is the whole selection moving. That is what a pick-selected stretch means. With nothing picked there is nothing to end, so Enter still cancels. The prompt says so. Not changed: with a prior selection, a window that misses every vertex still goes on to ask for the base point. The command holds wires, not entities, and the entity rules do not follow the wires — a small window inside a circle catches its centre, and so the whole circle, while no tessellated vertex is inside. Guarding on the wires would refuse that stretch. Closes #676 Co-Authored-By: Claude Opus 5 --- src/app/command_driver.rs | 26 ++++++++------- src/modules/draw/modify/stretch.rs | 52 +++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 26e9540ec..fcfaaf8ca 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2320,21 +2320,25 @@ impl OpenCADStudio { .filter(|&h| !scene.is_layer_locked(h)), ); } - if handles.is_empty() { + use crate::command::CadCommand; + use crate::modules::draw::modify::stretch::StretchCommand; + // A window that caught nothing is a missed aim, not a decision + // to stop. Ending the command there made the user restart it to + // try again; instead say so and ask for the corner afresh, the + // way a selection that picks nothing leaves MOVE still asking. + // (#676) + let cmd = if handles.is_empty() { self.command_line .push_output(crate::t!("STRETCH: nothing crosses the window.").as_ref()); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); + StretchCommand::new(Vec::new(), Vec::new()) } else { - use crate::command::CadCommand; - use crate::modules::draw::modify::stretch::StretchCommand; let wires = self.tabs[i].scene.wire_models_for(&handles); - let cmd = StretchCommand::with_window(handles, wires, win_min, win_max); - self.command_line.push_info(&CadCommand::prompt(&cmd)); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } + StretchCommand::with_window(handles, wires, win_min, win_max) + }; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.command_line.push_info(&CadCommand::prompt(&cmd)); + self.tabs[i].active_cmd = Some(Box::new(cmd)); } CmdResult::StretchEntities { handles, diff --git a/src/modules/draw/modify/stretch.rs b/src/modules/draw/modify/stretch.rs index 1a20eec4a..168a2b51b 100644 --- a/src/modules/draw/modify/stretch.rs +++ b/src/modules/draw/modify/stretch.rs @@ -81,6 +81,42 @@ impl StretchCommand { step: Step::Base { win_min, win_max }, } } + + /// A window enclosing every vertex of the current selection, or `None` + /// when nothing is selected. Padded so a vertex exactly on the boundary + /// counts as inside rather than depending on the comparison's edge. + fn selection_bounds(&self) -> Option<(DVec3, DVec3)> { + let mut min = DVec3::splat(f64::INFINITY); + let mut max = DVec3::splat(f64::NEG_INFINITY); + let mut any = false; + for wire in &self.wire_models { + for index in 0..wire.points.len() { + let point = wire_point(wire, index); + if !point.is_finite() { + continue; + } + min = min.min(point); + max = max.max(point); + any = true; + } + } + if !any { + return None; + } + let pad = (max - min).length().max(1.0) * 1e-6; + Some((min - DVec3::splat(pad), max + DVec3::splat(pad))) + } +} + +/// A wire vertex rebuilt from its double-single halves. +fn wire_point(wire: &WireModel, index: usize) -> DVec3 { + let high = wire.points[index]; + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ) } impl CadCommand for StretchCommand { @@ -96,8 +132,11 @@ impl CadCommand for StretchCommand { // marks which of their points move. t!("STRETCH Specify first corner of crossing window:").into_owned() } else { + // The window narrows a selection that already exists, so + // it is optional — Enter takes the whole of it, the way a + // selection ends in MOVE or COPY. (#676) t!( - "STRETCH Specify first corner of crossing window [%{count} objects]:", + "STRETCH Specify first corner of crossing window, or press Enter to stretch all [%{count} objects]:", count = self.handles.len() ) .into_owned() @@ -162,6 +201,17 @@ impl CadCommand for StretchCommand { } fn on_enter(&mut self) -> CmdResult { + // Enter ends the selection stage, as it does in MOVE and COPY. With + // objects already picked there is nothing left to choose: a window that + // contains all of them stretches every vertex, which is the whole + // selection moving — what a pick-selected stretch means. Without a + // selection there is nothing to end, so Enter still cancels. (#676) + if let Step::WindowCorner1 = self.step { + if let Some((win_min, win_max)) = self.selection_bounds() { + self.step = Step::Base { win_min, win_max }; + return CmdResult::NeedPoint; + } + } CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult {