diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8b6b2764e..e01f82af0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2568,48 +2568,56 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); return self.on_quick_print_handles(handles); } - CmdResult::StretchWindow { win_min, win_max } => { - // Implicit STRETCH selection (#338): the crossing window drawn - // with no prior selection picks the objects itself. Entities - // whose world AABB touches the window are handed back to the - // command at the base-point step — over-selection is harmless, - // since only points INSIDE the window move anyway. - let mut handles: Vec = Vec::new(); + CmdResult::StretchWindow { + mut handles, + windows, + } => { + // Accumulate every entity touched by any crossing window. Keep STRETCH + // in its selection stage; Enter is what advances to the base point. { let scene = &self.tabs[i].scene; - handles.extend( - scene - .interaction_handles_in_world_aabb([ - win_min.x, win_min.y, win_max.x, win_max.y, - ]) - .into_iter() - .filter(|&h| !scene.is_layer_locked(h)), + + for (win_min, win_max) in &windows { + handles.extend( + scene + .interaction_handles_in_world_aabb([ + win_min.x, + win_min.y, + win_max.x, + win_max.y, + ]) + .into_iter() + .filter(|&handle| !scene.is_layer_locked(handle)), + ); + } + } + + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + + if handles.is_empty() { + self.command_line.push_output( + crate::t!("STRETCH: nothing crosses the window.").as_ref(), ); } + 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()); - StretchCommand::new(Vec::new(), Vec::new()) - } else { - let wires = self.tabs[i].scene.wire_models_for(&handles); - StretchCommand::with_window(handles, wires, win_min, win_max) - }; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); + + let wires = self.tabs[i].scene.wire_models_for(&handles); + + let cmd = StretchCommand::with_windows( + handles, + wires, + windows, + ); + self.command_line.push_info(&CadCommand::prompt(&cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd)); } CmdResult::StretchEntities { mut handles, - win_min, - win_max, + windows, delta, } => { handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); @@ -2630,7 +2638,12 @@ impl OpenCADStudio { // Helper: is DXF point (x, y) inside the world-space window? // Drawing plane is world XY (= DXF XY). let in_win = |x: f64, y: f64| -> bool { - x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y + windows.iter().any(|(win_min, win_max)| { + x >= win_min.x + && x <= win_max.x + && y >= win_min.y + && y <= win_max.y + }) }; let dx = delta.x as f64; @@ -2739,8 +2752,14 @@ impl OpenCADStudio { } } acadrust::EntityType::Viewport(vp) => { - stretched = - crate::entities::viewport::stretch(vp, win_min, win_max, delta); + stretched = windows.iter().any(|(win_min, win_max)| { + crate::entities::viewport::stretch( + vp, + *win_min, + *win_max, + delta, + ) + }); } acadrust::EntityType::Dimension(dim) => { use acadrust::entities::Dimension; diff --git a/src/command.rs b/src/command.rs index d2473dc47..2faf989e4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1335,18 +1335,21 @@ pub enum CmdResult { scale: f32, angle: f32, }, - /// Implicit STRETCH selection: the crossing window was drawn with no prior - /// selection, so the host resolves which entities it touches and restarts - /// the command at the base-point step with them. (#338) - StretchWindow { win_min: DVec3, win_max: DVec3 }, - /// Stretch entities: move only vertices/endpoints inside the crossing window. + /// STRETCH crossing-window selection. The command can accumulate several + /// independent crossing windows before Enter ends the selection stage. + StretchWindow { + /// Handles already gathered by previous crossing windows / preselection. + handles: Vec, + /// Every crossing window gathered so far. + windows: Vec<(DVec3, DVec3)>, + }, + /// Stretch entities: move only vertices/endpoints inside any gathered + /// crossing window. StretchEntities { handles: Vec, - /// Min corner of the crossing window in world XZ (= DXF XY). - win_min: DVec3, - /// Max corner of the crossing window in world XZ (= DXF XY). - win_max: DVec3, - /// Translation vector to apply to vertices inside the window. + /// Independent crossing windows that define the points to move. + windows: Vec<(DVec3, DVec3)>, + /// Translation vector applied once to every selected point. delta: DVec3, }, /// Create a Solid3D placeholder entity + associated MeshModel. diff --git a/src/modules/draw/modify/stretch.rs b/src/modules/draw/modify/stretch.rs index 168a2b51b..52e615fbb 100644 --- a/src/modules/draw/modify/stretch.rs +++ b/src/modules/draw/modify/stretch.rs @@ -37,16 +37,14 @@ pub fn tool() -> ToolDef { // ── Command implementation ───────────────────────────────────────────────── enum Step { - /// Waiting for the first crossing-window corner. + /// Waiting for the first corner of another crossing window. WindowCorner1, - /// Waiting for the second corner; `c1` is the first corner. + /// Waiting for the opposite corner. WindowCorner2(DVec3), - /// Crossing window defined; waiting for base point. - Base { win_min: DVec3, win_max: DVec3 }, - /// Waiting for target point. + /// Selection is complete; waiting for the displacement base point. + Base, + /// Waiting for the displacement target. Target { - win_min: DVec3, - win_max: DVec3, base: DVec3, }, } @@ -54,6 +52,8 @@ enum Step { pub struct StretchCommand { handles: Vec, wire_models: Vec, + /// Independent crossing windows accumulated during the selection stage. + windows: Vec<(DVec3, DVec3)>, step: Step, } @@ -62,26 +62,25 @@ impl StretchCommand { Self { handles, wire_models, + windows: Vec::new(), step: Step::WindowCorner1, } } - /// Restart at the base-point step with the window already known — the - /// implicit-selection flow: the host resolved which entities the crossing - /// window touches and hands them in together with the window. (#338) - pub fn with_window( + /// Continue gathering crossing windows after the host has resolved the + /// entities touched by the latest window. + pub fn with_windows( handles: Vec, wire_models: Vec, - win_min: DVec3, - win_max: DVec3, + windows: Vec<(DVec3, DVec3)>, ) -> Self { Self { handles, wire_models, - step: Step::Base { win_min, win_max }, + windows, + step: Step::WindowCorner1, } } - /// 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. @@ -127,24 +126,22 @@ impl CadCommand for StretchCommand { fn prompt(&self) -> String { match &self.step { Step::WindowCorner1 => { - if self.handles.is_empty() { - // Implicit mode: the window both selects the objects and - // marks which of their points move. + if self.windows.is_empty() && self.handles.is_empty() { 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, or press Enter to stretch all [%{count} objects]:", - count = self.handles.len() + "STRETCH Specify first corner of another crossing window, or press Enter to continue:" ) .into_owned() } } - Step::WindowCorner2(_) => t!("STRETCH Specify opposite corner:").into_owned(), - Step::Base { .. } => t!("STRETCH Specify base point:").into_owned(), - Step::Target { base, .. } => { + Step::WindowCorner2(_) => { + t!("STRETCH Specify opposite corner:").into_owned() + } + Step::Base => { + t!("STRETCH Specify base point:").into_owned() + } + Step::Target { base } => { let bx = format!("{:.3}", base.x); let bz = format!("{:.3}", base.z); t!( @@ -163,37 +160,34 @@ impl CadCommand for StretchCommand { self.step = Step::WindowCorner2(pt); CmdResult::NeedPoint } + Step::WindowCorner2(c1) => { let win_min = c1.min(pt); let win_max = c1.max(pt); - if self.handles.is_empty() { - // Implicit mode: hand the window to the host, which - // resolves the touched entities and restarts the command - // at the base-point step via `with_window`. (#338) - return CmdResult::StretchWindow { win_min, win_max }; + + let mut windows = self.windows.clone(); + windows.push((win_min, win_max)); + + // Hand the accumulated selection back to the host. The host resolves + // the entities touched by this window and relaunches STRETCH still in + // the selection stage, so another crossing window can be drawn. + CmdResult::StretchWindow { + handles: self.handles.clone(), + windows, } - self.step = Step::Base { win_min, win_max }; + } + + Step::Base => { + self.step = Step::Target { base: pt }; CmdResult::NeedPoint } - Step::Base { win_min, win_max } => { - let (wmin, wmax) = (*win_min, *win_max); - self.step = Step::Target { - win_min: wmin, - win_max: wmax, - base: pt, - }; - CmdResult::NeedPoint - } - Step::Target { - win_min, - win_max, - base, - } => { + + Step::Target { base } => { let delta = pt - *base; + CmdResult::StretchEntities { handles: self.handles.clone(), - win_min: *win_min, - win_max: *win_max, + windows: self.windows.clone(), delta, } } @@ -201,17 +195,22 @@ 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 }; + // Preserve the existing preselection behaviour: if STRETCH started + // from a selected set and no explicit crossing window was drawn, + // treat its complete bounds as the stretch window. + if self.windows.is_empty() { + if let Some((win_min, win_max)) = self.selection_bounds() { + self.windows.push((win_min, win_max)); + } + } + + if !self.handles.is_empty() && !self.windows.is_empty() { + self.step = Step::Base; return CmdResult::NeedPoint; } } + CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { @@ -239,20 +238,25 @@ impl CadCommand for StretchCommand { // 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, - base, - } => { + Step::Target { base } => { let delta = pt - *base; - // Live ghost: vertices inside the crossing window follow the - // cursor, the rest stay anchored. This is the preview/GPU path, - // so downcast to f32 only at the WireModel boundary. + + let windows: Vec<_> = self + .windows + .iter() + .map(|(win_min, win_max)| { + (win_min.as_vec3(), win_max.as_vec3()) + }) + .collect(); + let mut out: Vec = self .wire_models .iter() - .map(|w| w.stretched((*win_min).as_vec3(), (*win_max).as_vec3(), delta.as_vec3())) + .map(|wire| { + wire.stretched_windows(&windows, delta.as_vec3()) + }) .collect(); + out.push(WireModel::solid( "rubber_band".into(), vec![ @@ -262,6 +266,7 @@ impl CadCommand for StretchCommand { WireModel::CYAN, false, )); + out } _ => vec![], diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index cf8de97e9..af7d1bbe8 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -321,30 +321,64 @@ impl WireModel { /// outside stay put. Exact for line/polyline vertices (the primary stretch /// targets); curve tessellation points may deform where a window edge cuts /// through them, matching the per-vertex nature of the operation. - pub fn stretched(&self, win_min: glam::Vec3, win_max: glam::Vec3, delta: glam::Vec3) -> Self { + pub fn stretched( + &self, + win_min: glam::Vec3, + win_max: glam::Vec3, + delta: glam::Vec3, +) -> Self { + self.stretched_windows(&[(win_min, win_max)], delta) +} + +/// Return a clone for a multi-window STRETCH preview. A point moves exactly +/// once when it lies inside any of the crossing windows. +pub fn stretched_windows( + &self, + windows: &[(glam::Vec3, glam::Vec3)], + delta: glam::Vec3, +) -> Self { let mut out = self.clone(); out.name = format!("preview_{}", self.name); out.color = Self::CYAN; out.selected = false; + + let inside = |x: f32, y: f32| { + windows.iter().any(|(win_min, win_max)| { + x >= win_min.x + && x <= win_max.x + && y >= win_min.y + && y <= win_max.y + }) + }; + for p in &mut out.points { - if p[0] >= win_min.x && p[0] <= win_max.x && p[1] >= win_min.y && p[1] <= win_max.y { + if inside(p[0], p[1]) { p[0] += delta.x; p[1] += delta.y; p[2] += delta.z; } } + if !out.text_verts.is_empty() { - let (mnx, mny) = (win_min.x as f64, win_min.y as f64); - let (mxx, mxy) = (win_max.x as f64, win_max.y as f64); - let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64); + let (dx, dy, dz) = + (delta.x as f64, delta.y as f64, delta.z as f64); + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { - if x >= mnx && x <= mxx && y >= mny && y <= mxy { + let inside = windows.iter().any(|(win_min, win_max)| { + x >= win_min.x as f64 + && x <= win_max.x as f64 + && y >= win_min.y as f64 + && y <= win_max.y as f64 + }); + + if inside { (x + dx, y + dy, z + dz) } else { (x, y, z) } }); } + out }