From 1c8d6e1978254f7468be71190187d46226fd894d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 21 Jul 2026 11:44:18 +0300 Subject: [PATCH] feat(offset): classic distance-first flow with a repeat loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OFFSET now opens with "Specify offset distance or [Through] ": a typed value locks the distance, bare Enter / Space accepts the last one (also offered as a prompt button), and T switches to through mode (#418). After each offset the command loops back to the object pick at the same distance until Enter / Esc finishes — Space acts as Enter throughout, where the old flow cancelled. The pick-first path (#422) keeps working: pre-selected objects jump from the distance step straight to the side click. Co-Authored-By: Claude Fable 5 --- src/modules/draw/modify/offset.rs | 171 ++++++++++++++++++++++-------- 1 file changed, 126 insertions(+), 45 deletions(-) diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index f330dc71c..b89dfd8a5 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -671,21 +671,29 @@ fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> { // ── Command implementation ───────────────────────────────────────────────── enum Step { - SelectObject, - // `locked == None` is "through" mode: the offset magnitude follows the - // cursor (perpendicular distance to the picked object). Typing a value - // locks the magnitude; the cursor then only chooses the side. + /// Classic first step (#418): type the offset distance, press Enter / + /// Space to accept the last one, or choose Through mode. + Distance, + /// Pick the object to offset. `locked == None` is "through" mode: the + /// magnitude follows the cursor (perpendicular distance to the object). + SelectObject { locked: Option }, PickSide { /// The object(s) being offset — one from a pick, or the whole /// pre-selection when OFFSET starts with objects selected (#422). targets: Vec, locked: Option, + /// True when the targets came from the pre-selection: the commit ends + /// the command instead of looping back for another pick. + from_selection: bool, }, } pub struct OffsetCommand { step: Step, all_entities: Vec, + /// Pre-selected offsettable objects (pick-first, #422); consumed when the + /// distance step resolves. + preselected: Vec, } /// The entity types `compute_offset` can offset. @@ -705,22 +713,37 @@ pub fn is_offsettable(e: &EntityType) -> bool { impl OffsetCommand { pub fn new(all_entities: Vec) -> Self { Self { - step: Step::SelectObject, + step: Step::Distance, all_entities, + preselected: Vec::new(), } } - /// Start straight at the side/distance step with the pre-selected - /// objects as targets — the pick-first flow MOVE / COPY use (#422). + /// Pick-first flow (#422): the distance step still comes first, then the + /// pre-selected objects go straight to the side step. pub fn with_selection(all_entities: Vec, targets: Vec) -> Self { Self { - step: Step::PickSide { - targets, - locked: None, - }, + step: Step::Distance, all_entities, + preselected: targets, } } + + /// Leave the distance step with the given mode (Some = locked distance, + /// None = through): pre-selected objects jump to the side step, otherwise + /// the object-pick loop starts. + fn advance_from_distance(&mut self, locked: Option) -> CmdResult { + if self.preselected.is_empty() { + self.step = Step::SelectObject { locked }; + } else { + self.step = Step::PickSide { + targets: std::mem::take(&mut self.preselected), + locked, + from_selection: true, + }; + } + CmdResult::NeedPoint + } } impl CadCommand for OffsetCommand { @@ -729,30 +752,51 @@ impl CadCommand for OffsetCommand { } fn prompt(&self) -> String { - let n = match &self.step { - Step::PickSide { targets, .. } if targets.len() > 1 => { - format!(" ({} objects)", targets.len()) - } - _ => String::new(), - }; match &self.step { - Step::SelectObject => "OFFSET Select object to offset:".into(), - Step::PickSide { - locked: Some(d), .. - } => format!("OFFSET{n} Click side [distance {d:.4}, type to change]:"), - Step::PickSide { locked: None, .. } => format!( - "OFFSET{n} Click through point, or type a distance (last {:.4}):", + Step::Distance => format!( + "OFFSET Specify offset distance or [Through] <{:.4}>:", defaults::get_offset_dist() ), + Step::SelectObject { .. } => { + "OFFSET Select object to offset (Enter to finish):".into() + } + Step::PickSide { targets, locked, .. } => { + let n = if targets.len() > 1 { + format!(" ({} objects)", targets.len()) + } else { + String::new() + }; + match locked { + Some(d) => format!("OFFSET{n} Click side [distance {d:.4}]:"), + None => format!("OFFSET{n} Click through point:"), + } + } + } + } + + fn options(&self) -> Vec { + match &self.step { + Step::Distance => vec![ + crate::command::CmdOption::new("Through", "T"), + crate::command::CmdOption::enter(&format!( + "{:.4}", + defaults::get_offset_dist() + )), + ], + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::SelectObject) + matches!(self.step, Step::SelectObject { .. }) } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() || !matches!(self.step, Step::SelectObject) { + let locked = match &self.step { + Step::SelectObject { locked } => *locked, + _ => return CmdResult::NeedPoint, + }; + if handle.is_null() { return CmdResult::NeedPoint; } @@ -768,7 +812,8 @@ impl CadCommand for OffsetCommand { Some(e) if is_offsettable(&e) => { self.step = Step::PickSide { targets: vec![e], - locked: None, + locked, + from_selection: false, }; CmdResult::NeedPoint } @@ -776,21 +821,22 @@ impl CadCommand for OffsetCommand { } } - // The side step accepts an optional typed magnitude — through mode by - // default, a fixed distance once the user types one. + // The distance step takes a typed magnitude; the side step accepts one + // too, re-locking the distance mid-command. fn wants_text_input(&self) -> bool { - matches!(self.step, Step::PickSide { .. }) + matches!(self.step, Step::Distance | Step::PickSide { .. }) } fn dyn_field(&self) -> crate::command::DynField { match self.step { - Step::PickSide { .. } => crate::command::DynField::Scalar, + Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar, _ => crate::command::DynField::Point, } } fn dyn_live_value(&self, cursor: DVec3) -> Option { match &self.step { + Step::Distance => Some(defaults::get_offset_dist()), Step::PickSide { targets, locked, .. } => Some(locked.unwrap_or_else(|| { targets .first() @@ -802,23 +848,36 @@ impl CadCommand for OffsetCommand { } fn on_text_input(&mut self, text: &str) -> Option { - if let Step::PickSide { locked, .. } = &mut self.step { - let t = text.trim().replace(',', "."); - if !t.is_empty() { + let t = text.trim().replace(',', "."); + match &mut self.step { + Step::Distance => { + if t.eq_ignore_ascii_case("t") || t.eq_ignore_ascii_case("through") { + return Some(self.advance_from_distance(None)); + } if let Ok(d) = t.parse::() { let d = d.abs().max(1e-9); defaults::set_offset_dist(d); - *locked = Some(d); + return Some(self.advance_from_distance(Some(d))); } + Some(CmdResult::NeedPoint) } - // Stay on the side step — the click chooses which side. - return Some(CmdResult::NeedPoint); + Step::PickSide { locked, .. } => { + if !t.is_empty() { + if let Ok(d) = t.parse::() { + let d = d.abs().max(1e-9); + defaults::set_offset_dist(d); + *locked = Some(d); + } + } + // Stay on the side step — the click chooses which side. + Some(CmdResult::NeedPoint) + } + _ => None, } - None } fn on_hover_entity(&mut self, handle: Handle, _pt: DVec3) -> Vec { - if handle.is_null() { + if handle.is_null() || !matches!(self.step, Step::SelectObject { .. }) { return vec![]; } if let Some(entity) = self @@ -840,8 +899,12 @@ impl CadCommand for OffsetCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - let (locked, targets) = match &self.step { - Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), + let (locked, targets, from_selection) = match &self.step { + Step::PickSide { + locked, + targets, + from_selection, + } => (*locked, targets.clone(), *from_selection), _ => return CmdResult::NeedPoint, }; // Each target offsets by its own through-distance (or the locked @@ -856,10 +919,20 @@ impl CadCommand for OffsetCommand { news.push(new_entity); } } - match news.len() { - 0 => CmdResult::NeedPoint, - 1 => CmdResult::CommitAndExit(news.pop().unwrap()), - _ => CmdResult::ReplaceMany(vec![], news), + if news.is_empty() { + return CmdResult::NeedPoint; + } + if from_selection { + // Pre-selection commit ends the command in one undo step. + return CmdResult::ReplaceMany(vec![], news); + } + // Classic loop (#418): commit this offset and go back to the object + // pick at the same distance, until Enter / Esc finishes. + self.step = Step::SelectObject { locked }; + if news.len() == 1 { + CmdResult::CommitEntity(news.pop().unwrap()) + } else { + CmdResult::ReplaceMany(vec![], news) } } @@ -890,7 +963,15 @@ impl CadCommand for OffsetCommand { } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match &self.step { + // Enter / Space on the distance step accepts the last distance — + // the "repeat the same value with just Space" flow (#418). + Step::Distance => { + let d = defaults::get_offset_dist(); + self.advance_from_distance(Some(d.abs().max(1e-9))) + } + _ => CmdResult::Cancel, + } } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel