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 <[email protected]>
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 ─────────────────────────────────────────────────
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<f64> },
PickSide {
/// The object(s) being offset — one from a pick, or the whole
/// pre-selection when OFFSET starts with objects selected (#422).
targets: Vec<EntityType>,
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 {
step: Step,
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.
@@ -705,22 +713,37 @@ pub fn is_offsettable(e: &EntityType) -> bool {
impl OffsetCommand {
pub fn new(all_entities: Vec<EntityType>) -> 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<EntityType>, targets: Vec<EntityType>) -> 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<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 {
@@ -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<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 {
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<f64> {
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<CmdResult> {
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::<f64>() {
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::<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> {
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