fix(stretch): let the selection stage behave like every other one

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-08 00:42:16 +03:00
1 parent 8e09a312aa
commit eb513ac5f8
2 files changed
+66 -12

No files matched your search

+15 -11
View File
@@ -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,
+51 -1
View File
@@ -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 {