feat(offset): classic distance-first flow with a repeat loop

OFFSET now opens with "Specify offset distance or [Through] <last>":
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 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-21 11:44:18 +03:00
1 parent 73bd304089
commit 1c8d6e1978
1 file changed
+126 -45
+126 -45
View File
@@ -671,21 +671,29 @@ fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> {
// ── Command implementation ───────────────────────────────────────────────── // ── Command implementation ─────────────────────────────────────────────────
enum Step { enum Step {
SelectObject, /// Classic first step (#418): type the offset distance, press Enter /
// `locked == None` is "through" mode: the offset magnitude follows the /// Space to accept the last one, or choose Through mode.
// cursor (perpendicular distance to the picked object). Typing a value Distance,
// locks the magnitude; the cursor then only chooses the side. /// Pick the object to offset. `locked == None` is "through" mode: the
/// magnitude follows the cursor (perpendicular distance to the object).
SelectObject { locked: Option<f64> },
PickSide { PickSide {
/// The object(s) being offset — one from a pick, or the whole /// The object(s) being offset — one from a pick, or the whole
/// pre-selection when OFFSET starts with objects selected (#422). /// pre-selection when OFFSET starts with objects selected (#422).
targets: Vec<EntityType>, targets: Vec<EntityType>,
locked: Option<f64>, locked: Option<f64>,
/// 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 { pub struct OffsetCommand {
step: Step, step: Step,
all_entities: Vec<EntityType>, all_entities: Vec<EntityType>,
/// Pre-selected offsettable objects (pick-first, #422); consumed when the
/// distance step resolves.
preselected: Vec<EntityType>,
} }
/// The entity types `compute_offset` can offset. /// The entity types `compute_offset` can offset.
@@ -705,22 +713,37 @@ pub fn is_offsettable(e: &EntityType) -> bool {
impl OffsetCommand { impl OffsetCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self { pub fn new(all_entities: Vec<EntityType>) -> Self {
Self { Self {
step: Step::SelectObject, step: Step::Distance,
all_entities, all_entities,
preselected: Vec::new(),
} }
} }
/// Start straight at the side/distance step with the pre-selected /// Pick-first flow (#422): the distance step still comes first, then the
/// objects as targets — the pick-first flow MOVE / COPY use (#422). /// pre-selected objects go straight to the side step.
pub fn with_selection(all_entities: Vec<EntityType>, targets: Vec<EntityType>) -> Self { pub fn with_selection(all_entities: Vec<EntityType>, targets: Vec<EntityType>) -> Self {
Self { Self {
step: Step::PickSide { step: Step::Distance,
targets,
locked: None,
},
all_entities, 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<f64>) -> 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 { impl CadCommand for OffsetCommand {
@@ -729,30 +752,51 @@ impl CadCommand for OffsetCommand {
} }
fn prompt(&self) -> String { 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 { match &self.step {
Step::SelectObject => "OFFSET Select object to offset:".into(), Step::Distance => format!(
Step::PickSide { "OFFSET Specify offset distance or [Through] <{:.4}>:",
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}):",
defaults::get_offset_dist() 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<crate::command::CmdOption> {
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 { 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 { 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; return CmdResult::NeedPoint;
} }
@@ -768,7 +812,8 @@ impl CadCommand for OffsetCommand {
Some(e) if is_offsettable(&e) => { Some(e) if is_offsettable(&e) => {
self.step = Step::PickSide { self.step = Step::PickSide {
targets: vec![e], targets: vec![e],
locked: None, locked,
from_selection: false,
}; };
CmdResult::NeedPoint CmdResult::NeedPoint
} }
@@ -776,21 +821,22 @@ impl CadCommand for OffsetCommand {
} }
} }
// The side step accepts an optional typed magnitude — through mode by // The distance step takes a typed magnitude; the side step accepts one
// default, a fixed distance once the user types one. // too, re-locking the distance mid-command.
fn wants_text_input(&self) -> bool { 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 { fn dyn_field(&self) -> crate::command::DynField {
match self.step { match self.step {
Step::PickSide { .. } => crate::command::DynField::Scalar, Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar,
_ => crate::command::DynField::Point, _ => crate::command::DynField::Point,
} }
} }
fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> { fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> {
match &self.step { match &self.step {
Step::Distance => Some(defaults::get_offset_dist()),
Step::PickSide { targets, locked, .. } => Some(locked.unwrap_or_else(|| { Step::PickSide { targets, locked, .. } => Some(locked.unwrap_or_else(|| {
targets targets
.first() .first()
@@ -802,23 +848,36 @@ impl CadCommand for OffsetCommand {
} }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::PickSide { locked, .. } = &mut self.step { let t = text.trim().replace(',', ".");
let t = text.trim().replace(',', "."); match &mut self.step {
if !t.is_empty() { 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::<f64>() { if let Ok(d) = t.parse::<f64>() {
let d = d.abs().max(1e-9); let d = d.abs().max(1e-9);
defaults::set_offset_dist(d); 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. Step::PickSide { locked, .. } => {
return Some(CmdResult::NeedPoint); if !t.is_empty() {
if let Ok(d) = t.parse::<f64>() {
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<WireModel> { fn on_hover_entity(&mut self, handle: Handle, _pt: DVec3) -> Vec<WireModel> {
if handle.is_null() { if handle.is_null() || !matches!(self.step, Step::SelectObject { .. }) {
return vec![]; return vec![];
} }
if let Some(entity) = self if let Some(entity) = self
@@ -840,8 +899,12 @@ impl CadCommand for OffsetCommand {
} }
fn on_point(&mut self, pt: DVec3) -> CmdResult { fn on_point(&mut self, pt: DVec3) -> CmdResult {
let (locked, targets) = match &self.step { let (locked, targets, from_selection) = match &self.step {
Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), Step::PickSide {
locked,
targets,
from_selection,
} => (*locked, targets.clone(), *from_selection),
_ => return CmdResult::NeedPoint, _ => return CmdResult::NeedPoint,
}; };
// Each target offsets by its own through-distance (or the locked // Each target offsets by its own through-distance (or the locked
@@ -856,10 +919,20 @@ impl CadCommand for OffsetCommand {
news.push(new_entity); news.push(new_entity);
} }
} }
match news.len() { if news.is_empty() {
0 => CmdResult::NeedPoint, return CmdResult::NeedPoint;
1 => CmdResult::CommitAndExit(news.pop().unwrap()), }
_ => CmdResult::ReplaceMany(vec![], news), 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 { 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 { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel