feat(offset): start from the pre-selected objects

OFFSET now follows the pick-first flow of MOVE / COPY (#422): with
offsettable objects already selected it skips the pick step and goes
straight to the distance / side step, offsetting every selected object
toward the clicked side — each through the cursor point in through
mode, all by the typed magnitude once one is locked. Preview covers
every target and a multi-object result commits as one undo step; with
nothing selected the command gathers a pick exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-21 11:20:04 +03:00
commit 33864bace6
2 changed files with 96 additions and 45 deletions

View file

@ -943,7 +943,7 @@ impl OpenCADStudio {
} }
"OFFSET" => { "OFFSET" => {
use crate::modules::draw::modify::offset::OffsetCommand; use crate::modules::draw::modify::offset::{is_offsettable, OffsetCommand};
let all_entities: Vec<_> = self.tabs[i] let all_entities: Vec<_> = self.tabs[i]
.scene .scene
.entity_wires() .entity_wires()
@ -953,7 +953,20 @@ impl OpenCADStudio {
self.tabs[i].scene.document.get_entity(h).cloned() self.tabs[i].scene.document.get_entity(h).cloned()
}) })
.collect(); .collect();
let new_cmd = OffsetCommand::new(all_entities); // Pick-first (#422): with offsettable objects already selected,
// skip the pick step and go straight to distance / side.
let preselected: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(_, e)| e.clone())
.filter(is_offsettable)
.collect();
let new_cmd = if preselected.is_empty() {
OffsetCommand::new(all_entities)
} else {
OffsetCommand::with_selection(all_entities, preselected)
};
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.tabs[i].active_cmd = Some(Box::new(new_cmd));
} }

View file

@ -676,9 +676,9 @@ enum Step {
// cursor (perpendicular distance to the picked object). Typing a value // cursor (perpendicular distance to the picked object). Typing a value
// locks the magnitude; the cursor then only chooses the side. // locks the magnitude; the cursor then only chooses the side.
PickSide { PickSide {
#[allow(dead_code)] /// The object(s) being offset — one from a pick, or the whole
handle: Handle, /// pre-selection when OFFSET starts with objects selected (#422).
entity: EntityType, targets: Vec<EntityType>,
locked: Option<f64>, locked: Option<f64>,
}, },
} }
@ -688,6 +688,20 @@ pub struct OffsetCommand {
all_entities: Vec<EntityType>, all_entities: Vec<EntityType>,
} }
/// The entity types `compute_offset` can offset.
pub fn is_offsettable(e: &EntityType) -> bool {
matches!(
e,
EntityType::Line(_)
| EntityType::Circle(_)
| EntityType::Arc(_)
| EntityType::LwPolyline(_)
| EntityType::Ellipse(_)
| EntityType::Spline(_)
| EntityType::XLine(_)
)
}
impl OffsetCommand { impl OffsetCommand {
pub fn new(all_entities: Vec<EntityType>) -> Self { pub fn new(all_entities: Vec<EntityType>) -> Self {
Self { Self {
@ -695,6 +709,18 @@ impl OffsetCommand {
all_entities, all_entities,
} }
} }
/// Start straight at the side/distance step with the pre-selected
/// objects as targets — the pick-first flow MOVE / COPY use (#422).
pub fn with_selection(all_entities: Vec<EntityType>, targets: Vec<EntityType>) -> Self {
Self {
step: Step::PickSide {
targets,
locked: None,
},
all_entities,
}
}
} }
impl CadCommand for OffsetCommand { impl CadCommand for OffsetCommand {
@ -703,13 +729,19 @@ 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::SelectObject => "OFFSET Select object to offset:".into(),
Step::PickSide { Step::PickSide {
locked: Some(d), .. locked: Some(d), ..
} => format!("OFFSET Click side [distance {d:.4}, type to change]:"), } => format!("OFFSET{n} Click side [distance {d:.4}, type to change]:"),
Step::PickSide { locked: None, .. } => format!( Step::PickSide { locked: None, .. } => format!(
"OFFSET Click through point, or type a distance (last {:.4}):", "OFFSET{n} Click through point, or type a distance (last {:.4}):",
defaults::get_offset_dist() defaults::get_offset_dist()
), ),
} }
@ -733,16 +765,9 @@ impl CadCommand for OffsetCommand {
// Accept every type compute_offset can offset — including XLine (#296), // Accept every type compute_offset can offset — including XLine (#296),
// and Ellipse/Spline whose offset functions existed but weren't reachable. // and Ellipse/Spline whose offset functions existed but weren't reachable.
match entity { match entity {
Some(e @ EntityType::Line(_)) Some(e) if is_offsettable(&e) => {
| Some(e @ EntityType::Circle(_))
| Some(e @ EntityType::Arc(_))
| Some(e @ EntityType::LwPolyline(_))
| Some(e @ EntityType::Ellipse(_))
| Some(e @ EntityType::Spline(_))
| Some(e @ EntityType::XLine(_)) => {
self.step = Step::PickSide { self.step = Step::PickSide {
handle, targets: vec![e],
entity: e,
locked: None, locked: None,
}; };
CmdResult::NeedPoint CmdResult::NeedPoint
@ -766,9 +791,12 @@ impl CadCommand for OffsetCommand {
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::PickSide { entity, locked, .. } => { Step::PickSide { targets, locked, .. } => Some(locked.unwrap_or_else(|| {
Some(locked.unwrap_or_else(|| perp_distance(entity, cursor.as_vec3()))) targets
} .first()
.map(|e| perp_distance(e, cursor.as_vec3()))
.unwrap_or(0.0)
})),
_ => None, _ => None,
} }
} }
@ -812,43 +840,53 @@ impl CadCommand for OffsetCommand {
} }
fn on_point(&mut self, pt: DVec3) -> CmdResult { fn on_point(&mut self, pt: DVec3) -> CmdResult {
let (locked, entity) = match &self.step { let (locked, targets) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()), Step::PickSide { locked, targets, .. } => (*locked, targets.clone()),
_ => return CmdResult::NeedPoint, _ => return CmdResult::NeedPoint,
}; };
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt.as_vec3())); // Each target offsets by its own through-distance (or the locked
// magnitude), toward the clicked side.
let mut news: Vec<EntityType> = Vec::new();
for entity in &targets {
let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3()));
if mag < 1e-9 { if mag < 1e-9 {
return CmdResult::NeedPoint; continue;
} }
if let Some(new_entity) = compute_offset(entity, mag, pt.as_vec3()) {
match compute_offset(&entity, mag, pt.as_vec3()) { news.push(new_entity);
Some(new_entity) => CmdResult::CommitAndExit(new_entity), }
None => CmdResult::NeedPoint, }
match news.len() {
0 => CmdResult::NeedPoint,
1 => CmdResult::CommitAndExit(news.pop().unwrap()),
_ => CmdResult::ReplaceMany(vec![], news),
} }
} }
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> { fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
let (locked, entity) = match &self.step { let (locked, targets) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()), Step::PickSide { locked, targets, .. } => (*locked, targets.clone()),
_ => return vec![], _ => return vec![],
}; };
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt.as_vec3())); let mut wires = Vec::new();
for (n, entity) in targets.iter().enumerate() {
let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3()));
if mag < 1e-9 { if mag < 1e-9 {
return vec![]; continue;
} }
if let Some(result) = compute_offset(entity, mag, pt.as_vec3()) {
if let Some(result) = compute_offset(&entity, mag, pt.as_vec3()) {
let pts = entity_wire_pts(&result); let pts = entity_wire_pts(&result);
if !pts.is_empty() { if !pts.is_empty() {
return vec![WireModel::solid( wires.push(WireModel::solid(
"offset_preview".into(), format!("offset_preview_{n}"),
pts, pts,
WireModel::CYAN, WireModel::CYAN,
false, false,
)]; ));
} }
} }
vec![] }
wires
} }
fn on_enter(&mut self) -> CmdResult { fn on_enter(&mut self) -> CmdResult {