forked from 1b-agent/opencadstudio-upstream
Merge pull request #1193 from ramox81/codex/complete-lengthen-options
This commit is contained in:
commit
9e74df4c67
2 files changed
+325
-173
No files matched your search
@@ -4,10 +4,11 @@
|
||||
// 1. Select objects (Enter to finish selection)
|
||||
// 2. First source point → first destination point
|
||||
// 3. Second source point → second destination point (Enter to skip = translate only)
|
||||
// 4. Enter = apply (scale = optional: Y/N prompt after 2nd pair)
|
||||
// 4. Continue for a third pair, or press Enter to choose two-pair scaling
|
||||
//
|
||||
// With 1 pair: pure translation (src1 → dst1)
|
||||
// With 2 pairs: translate + rotate (+ optional uniform scale to fit)
|
||||
// With 3 pairs: rigid 3D placement without scaling
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::DVec3;
|
||||
@@ -303,3 +304,50 @@ impl AlignCommand {
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn three_pairs_apply_a_rigid_spatial_frame() {
|
||||
let handle = Handle::new(9);
|
||||
let mut command = AlignCommand::with_selection(vec![handle]);
|
||||
let source = [DVec3::ZERO, DVec3::X, DVec3::Y];
|
||||
let origin = DVec3::new(5.0, 6.0, 7.0);
|
||||
let target = [origin, origin + DVec3::Y, origin + DVec3::Z];
|
||||
|
||||
assert!(matches!(command.on_point(DVec3::splat(f64::NAN)), CmdResult::NeedPoint));
|
||||
for point in [source[0], target[0], source[1], target[1]] {
|
||||
assert!(matches!(command.on_point(point), CmdResult::NeedPoint));
|
||||
}
|
||||
assert!(matches!(command.on_point(DVec3::X * 2.0), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.state, AlignState::Src3));
|
||||
assert!(matches!(command.on_point(source[2]), CmdResult::NeedPoint));
|
||||
|
||||
let CmdResult::TransformSelected(handles, EntityTransform::Affine(transform)) =
|
||||
command.on_point(target[2])
|
||||
else {
|
||||
panic!("third destination must complete alignment");
|
||||
};
|
||||
assert_eq!(handles, vec![handle]);
|
||||
for (from, expected) in source.into_iter().zip(target) {
|
||||
let actual = transform.apply(acadrust::types::Vector3::new(from.x, from.y, from.z));
|
||||
let actual = DVec3::new(actual.x, actual.y, actual.z);
|
||||
assert!(actual.abs_diff_eq(expected, 1.0e-12));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pair_still_translates_without_requesting_a_frame() {
|
||||
let handle = Handle::new(3);
|
||||
let mut command = AlignCommand::with_selection(vec![handle]);
|
||||
assert!(matches!(command.on_point(DVec3::new(1.0, 2.0, 3.0)), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::new(4.0, 6.0, 8.0)), CmdResult::NeedPoint));
|
||||
assert!(matches!(
|
||||
command.on_enter(),
|
||||
CmdResult::TransformSelected(handles, EntityTransform::Translate(delta))
|
||||
if handles == vec![handle] && delta == DVec3::new(3.0, 4.0, 5.0)
|
||||
));
|
||||
}
|
||||
}
|
||||
+276
-172
@@ -1,6 +1,6 @@
|
||||
// LENGTHEN command — extend or trim a Line or Arc by a specified delta or total.
|
||||
//
|
||||
// Options (entered as text after the entity pick):
|
||||
// Choose an option, enter its value, then pick objects repeatedly:
|
||||
// DE <value> — extend by delta (positive extends, negative trims)
|
||||
// TO <value> — set total length (Line) or arc length (Arc)
|
||||
// P <pct> — change by percentage (100 = no change, 150 = +50%)
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs};
|
||||
use acadrust::entities::{
|
||||
Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt,
|
||||
Ellipse as EllipseEnt, LwPolyline, Spline as SplineEnt,
|
||||
};
|
||||
use cadkernel::geom2d::{
|
||||
Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc,
|
||||
@@ -25,120 +25,191 @@ const TAU: f64 = std::f64::consts::TAU;
|
||||
|
||||
pub struct LengthenCommand {
|
||||
state: LenState,
|
||||
picked: Option<EntityType>,
|
||||
measurement: Option<f64>,
|
||||
edits: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ValueMode { Delta, Total, Percent, DeltaAngle, TotalAngle, Dynamic }
|
||||
|
||||
struct LengthenDefaults { mode: ValueMode, delta: f64, total: f64, percent: f64, delta_angle: f64, total_angle: f64 }
|
||||
static LENGTHEN_DEFAULTS: std::sync::Mutex<LengthenDefaults> = std::sync::Mutex::new(
|
||||
LengthenDefaults { mode: ValueMode::Total, delta: 0.0, total: 1.0, percent: 100.0, delta_angle: 0.0, total_angle: 180.0 }
|
||||
);
|
||||
|
||||
enum LenState {
|
||||
PickEntity,
|
||||
PickOption { handle: Handle, pick_pt: Vec3 },
|
||||
ChooseMode,
|
||||
Value(ValueMode),
|
||||
Apply(LenMode),
|
||||
DynamicPick,
|
||||
DynamicPoint { handle: Handle, entity: EntityType, pick: DVec3 },
|
||||
}
|
||||
|
||||
impl LengthenCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: LenState::PickEntity,
|
||||
}
|
||||
Self { state: LenState::ChooseMode, picked: None, measurement: None, edits: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for LengthenCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"LENGTHEN"
|
||||
}
|
||||
fn name(&self) -> &'static str { "LENGTHEN" }
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.state {
|
||||
LenState::PickEntity => t!("LENGTHEN Select object:").into_owned(),
|
||||
LenState::PickOption { .. } => {
|
||||
t!("LENGTHEN Enter option [DE <delta> / TO <total> / P <pct>]:").into_owned()
|
||||
LenState::ChooseMode => {
|
||||
let prompt = t!("LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]:");
|
||||
self.measurement.map_or_else(|| prompt.to_string(), |length| format!("Length: {length:.4} {prompt}"))
|
||||
}
|
||||
LenState::Value(ValueMode::Delta) => format!("LENGTHEN Enter delta length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta),
|
||||
LenState::Value(ValueMode::Total) => format!("LENGTHEN Enter total length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total),
|
||||
LenState::Value(ValueMode::Percent) => format!("LENGTHEN Enter percentage length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().percent),
|
||||
LenState::Value(ValueMode::DeltaAngle) => format!("LENGTHEN Enter delta angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta_angle),
|
||||
LenState::Value(ValueMode::TotalAngle) => format!("LENGTHEN Enter total angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total_angle),
|
||||
LenState::DynamicPoint { .. } => "LENGTHEN Specify new end point:".into(),
|
||||
LenState::Apply(_) | LenState::DynamicPick | LenState::Value(ValueMode::Dynamic) => t!("LENGTHEN Select an object to change or [Undo]:").into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.state, LenState::PickEntity)
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { let pt = pt.as_vec3();
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
match self.state {
|
||||
LenState::ChooseMode => vec![CmdOption::new("Delta", "DE"), CmdOption::new("Percent", "P"), CmdOption::new("Total", "TO"), CmdOption::new("Dynamic", "DY")],
|
||||
LenState::Value(ValueMode::Delta | ValueMode::Total) => vec![CmdOption::new("Angle", "A")],
|
||||
LenState::Apply(_) | LenState::DynamicPick => vec![CmdOption::new("Undo", "U")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
self.state = LenState::PickOption {
|
||||
handle,
|
||||
pick_pt: pt,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.state, LenState::PickOption { .. })
|
||||
fn needs_entity_pick(&self) -> bool { matches!(self.state, LenState::ChooseMode | LenState::Apply(_) | LenState::DynamicPick) }
|
||||
fn inject_before_entity_pick(&self) -> bool { true }
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); }
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult {
|
||||
if handle.is_null() { return CmdResult::NeedPoint; }
|
||||
let Some(entity) = self.picked.take() else { return CmdResult::NeedPoint; };
|
||||
match &self.state {
|
||||
LenState::ChooseMode => {
|
||||
self.measurement = crate::entities::curve::entity_curve(&entity)
|
||||
.map(|curve| curve.curve.length()).filter(|length| length.is_finite());
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
LenState::Apply(mode) => match lengthen_entity_precise(&entity, pt, mode) {
|
||||
Some(replacement) => CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]),
|
||||
None => CmdResult::NeedPoint,
|
||||
},
|
||||
LenState::DynamicPick if matches!(entity, EntityType::Line(_) | EntityType::Arc(_)) => {
|
||||
self.state = LenState::DynamicPoint { handle, entity, pick: pt };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_entity_replaced(&mut self, _old: Handle, _new_handles: &[Handle]) { self.edits += 1; }
|
||||
fn wants_text_input(&self) -> bool { true }
|
||||
fn dyn_commit_as_text(&self) -> bool { matches!(self.state, LenState::Value(_)) }
|
||||
fn dyn_auto_sign_angle(&self) -> bool { false }
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if matches!(self.state, LenState::PickOption { .. }) {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
if matches!(self.state, LenState::Value(ValueMode::DeltaAngle | ValueMode::TotalAngle)) { crate::command::DynField::Angle }
|
||||
else if matches!(self.state, LenState::Value(_)) { crate::command::DynField::Scalar }
|
||||
else { crate::command::DynField::Point }
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let (handle, pick_pt) = match &self.state {
|
||||
LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt),
|
||||
_ => return None,
|
||||
};
|
||||
let pick_pt = pick_pt.as_dvec3();
|
||||
|
||||
let text = text.trim().to_uppercase();
|
||||
if let Some(rest) = text.strip_prefix("DE ").or_else(|| text.strip_prefix("DE")) {
|
||||
let delta: f64 = rest.trim().replace(',', ".").parse().ok()?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Delta(delta),
|
||||
})
|
||||
} else if let Some(rest) = text.strip_prefix("TO ").or_else(|| text.strip_prefix("TO")) {
|
||||
let total: f64 = rest
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&v: &f64| v > 0.0)?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Total(total),
|
||||
})
|
||||
} else if let Some(rest) = text.strip_prefix("P ").or_else(|| text.strip_prefix("P")) {
|
||||
let pct: f64 = rest
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&v: &f64| v > 0.0)?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Percent(pct),
|
||||
})
|
||||
} else {
|
||||
// Try plain number as delta
|
||||
let delta: f64 = text.replace(',', ".").parse().ok()?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Delta(delta),
|
||||
})
|
||||
if matches!(self.state, LenState::DynamicPoint { .. }) { return None; }
|
||||
let upper = text.trim().to_uppercase();
|
||||
if upper.is_empty() { return Some(self.on_enter()); }
|
||||
if matches!(self.state, LenState::Apply(_) | LenState::DynamicPick) {
|
||||
return Some(if matches!(upper.as_str(), "U" | "UNDO") && self.edits > 0 {
|
||||
self.edits -= 1;
|
||||
CmdResult::UndoDocument
|
||||
} else { CmdResult::NeedPoint });
|
||||
}
|
||||
if matches!(self.state, LenState::ChooseMode) {
|
||||
let mut parts = upper.split_whitespace();
|
||||
let mode = match parts.next().unwrap_or("") {
|
||||
"D" | "DE" | "DELTA" => ValueMode::Delta,
|
||||
"T" | "TO" | "TOTAL" => ValueMode::Total,
|
||||
"P" | "PERCENT" => ValueMode::Percent,
|
||||
"DY" | "DYNAMIC" => ValueMode::Dynamic,
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
};
|
||||
LENGTHEN_DEFAULTS.lock().unwrap().mode = mode;
|
||||
self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) };
|
||||
if let Some(value) = parts.next() { return self.on_text_input(value); }
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let LenState::Value(mode) = self.state else { return Some(CmdResult::NeedPoint); };
|
||||
if matches!(upper.as_str(), "A" | "ANGLE") {
|
||||
self.state = match mode {
|
||||
ValueMode::Delta => LenState::Value(ValueMode::DeltaAngle),
|
||||
ValueMode::Total => LenState::Value(ValueMode::TotalAngle),
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
};
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let Some(value) = upper.replace(',', ".").parse::<f64>().ok().filter(|v| v.is_finite()) else {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
};
|
||||
if !matches!(mode, ValueMode::Delta | ValueMode::DeltaAngle) && value <= 0.0 { return Some(CmdResult::NeedPoint); }
|
||||
if matches!(mode, ValueMode::TotalAngle) && value >= 360.0 { return Some(CmdResult::NeedPoint); }
|
||||
{
|
||||
let mut defaults = LENGTHEN_DEFAULTS.lock().unwrap();
|
||||
match mode { ValueMode::Delta => defaults.delta = value, ValueMode::Total => defaults.total = value, ValueMode::Percent => defaults.percent = value,
|
||||
ValueMode::DeltaAngle => defaults.delta_angle = value, ValueMode::TotalAngle => defaults.total_angle = value, ValueMode::Dynamic => {} }
|
||||
}
|
||||
self.state = LenState::Apply(match mode {
|
||||
ValueMode::Delta => LenMode::Delta(value),
|
||||
ValueMode::Total => LenMode::Total(value),
|
||||
ValueMode::Percent => LenMode::Percent(value),
|
||||
ValueMode::DeltaAngle => LenMode::DeltaAngle(value.to_radians()),
|
||||
ValueMode::TotalAngle => LenMode::TotalAngle(value.to_radians()),
|
||||
ValueMode::Dynamic => return Some(CmdResult::NeedPoint),
|
||||
});
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if let LenState::DynamicPoint { handle, entity, pick } = &self.state {
|
||||
if let Some(replacement) = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt)) {
|
||||
let handle = *handle;
|
||||
self.state = LenState::DynamicPick;
|
||||
return CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]);
|
||||
}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<crate::scene::model::wire_model::WireModel> {
|
||||
let LenState::DynamicPoint { entity, pick, .. } = &self.state else { return None; };
|
||||
let replacement = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt))?;
|
||||
let curve = crate::entities::curve::entity_curve(&replacement)?;
|
||||
let points = crate::entities::curve::curve_points(&curve).into_iter()
|
||||
.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
|
||||
Some(crate::scene::model::wire_model::WireModel::solid(
|
||||
"lengthen_dynamic_preview".into(), points,
|
||||
crate::scene::model::wire_model::WireModel::CYAN, false,
|
||||
))
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.state {
|
||||
LenState::ChooseMode => {
|
||||
let mode = LENGTHEN_DEFAULTS.lock().unwrap().mode;
|
||||
self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
LenState::Value(mode) => {
|
||||
let value = {
|
||||
let defaults = LENGTHEN_DEFAULTS.lock().unwrap();
|
||||
match mode { ValueMode::Delta => defaults.delta, ValueMode::Total => defaults.total, ValueMode::Percent => defaults.percent,
|
||||
ValueMode::DeltaAngle => defaults.delta_angle, ValueMode::TotalAngle => defaults.total_angle, ValueMode::Dynamic => 0.0 }
|
||||
};
|
||||
self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode enum (also used in CmdResult) ────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -146,6 +217,9 @@ pub enum LenMode {
|
||||
Delta(f64),
|
||||
Total(f64),
|
||||
Percent(f64),
|
||||
DeltaAngle(f64),
|
||||
TotalAngle(f64),
|
||||
Dynamic(DVec3),
|
||||
}
|
||||
|
||||
// ── Geometry ───────────────────────────────────────────────────────────────
|
||||
@@ -153,96 +227,48 @@ pub enum LenMode {
|
||||
/// Apply LENGTHEN to a Line, Arc, Ellipse, or Spline.
|
||||
/// `pick_pt` determines which end to extend/trim (closest end is modified).
|
||||
pub fn lengthen_entity(entity: &EntityType, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
lengthen_entity_precise(entity, pick_pt.as_dvec3(), mode)
|
||||
}
|
||||
|
||||
fn lengthen_entity_precise(entity: &EntityType, pick: DVec3, mode: &LenMode) -> Option<EntityType> {
|
||||
use cadkernel::space::lengthen::{LengthChange, lengthen_line, lengthen_arc};
|
||||
let change = match mode {
|
||||
LenMode::Delta(value) => LengthChange::Delta(*value),
|
||||
LenMode::Total(value) => LengthChange::Total(*value),
|
||||
LenMode::Percent(value) => LengthChange::Percent(*value),
|
||||
LenMode::DeltaAngle(value) => LengthChange::DeltaAngle(*value),
|
||||
LenMode::TotalAngle(value) => LengthChange::TotalAngle(*value),
|
||||
LenMode::Dynamic(point) => LengthChange::Dynamic(point.to_array()),
|
||||
};
|
||||
match entity {
|
||||
EntityType::Line(l) => lengthen_line(l, pick_pt, mode),
|
||||
EntityType::Arc(a) => lengthen_arc(a, pick_pt, mode),
|
||||
EntityType::Ellipse(e) => lengthen_ellipse(e, pick_pt, mode),
|
||||
EntityType::Spline(s) => lengthen_spline(s, pick_pt, mode),
|
||||
EntityType::Line(line) => {
|
||||
let [start, end] = lengthen_line(
|
||||
[line.start.x, line.start.y, line.start.z],
|
||||
[line.end.x, line.end.y, line.end.z], pick.to_array(), change)?;
|
||||
let mut result = line.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
result.start = Vector3::new(start[0], start[1], start[2]);
|
||||
result.end = Vector3::new(end[0], end[1], end[2]);
|
||||
Some(EntityType::Line(result))
|
||||
}
|
||||
EntityType::Arc(arc) => {
|
||||
let (start, end) = lengthen_arc(&crate::entities::curve::arc_curve(arc), pick.to_array(), change)?;
|
||||
let mut result = arc.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
result.start_angle = start;
|
||||
result.end_angle = end;
|
||||
Some(EntityType::Arc(result))
|
||||
}
|
||||
EntityType::Ellipse(e) => lengthen_ellipse(e, pick.as_vec3(), mode),
|
||||
EntityType::Spline(s) => lengthen_spline(s, pick.as_vec3(), mode),
|
||||
EntityType::LwPolyline(p) => {
|
||||
let p = crate::entities::curve::lwpolyline_world_xy(p)?;
|
||||
lengthen_lwpoly(&p, pick_pt, mode)
|
||||
lengthen_lwpoly(&p, pick.as_vec3(), mode)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
// Keep the line's own coordinates on the f64 grid; only pick_pt (screen-only,
|
||||
// nearest-end selection) widens for the distance compare.
|
||||
let sx = line.start.x;
|
||||
let sy = line.start.y;
|
||||
let ex = line.end.x;
|
||||
let ey = line.end.y;
|
||||
|
||||
let dx = ex - sx;
|
||||
let dy = ey - sy;
|
||||
let current_len = (dx * dx + dy * dy).sqrt();
|
||||
if current_len < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_len = apply_mode(current_len, mode)?;
|
||||
if new_len < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ux = dx / current_len;
|
||||
let uy = dy / current_len;
|
||||
|
||||
// Which end is closer to pick?
|
||||
let px = pick_pt.x as f64;
|
||||
let py = pick_pt.y as f64;
|
||||
let dist_to_start = (px - sx).hypot(py - sy);
|
||||
let dist_to_end = (px - ex).hypot(py - ey);
|
||||
|
||||
let mut result = line.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
|
||||
if dist_to_end <= dist_to_start {
|
||||
// Extend/trim the end
|
||||
let new_x = sx + ux * new_len;
|
||||
let new_y = sy + uy * new_len;
|
||||
result.end = Vector3::new(new_x, new_y, line.end.z);
|
||||
} else {
|
||||
// Extend/trim the start (move start backward along dir)
|
||||
let new_x = ex - ux * new_len;
|
||||
let new_y = ey - uy * new_len;
|
||||
result.start = Vector3::new(new_x, new_y, line.start.z);
|
||||
}
|
||||
Some(EntityType::Line(result))
|
||||
}
|
||||
|
||||
fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
// Current arc span
|
||||
let span = arc_span_rad(arc.start_angle, arc.end_angle);
|
||||
let current_arc_len = arc.radius * span;
|
||||
|
||||
let new_arc_len = apply_mode(current_arc_len, mode)?;
|
||||
if new_arc_len < 1e-10 || new_arc_len >= std::f64::consts::TAU * arc.radius - 1.0e-9 {
|
||||
return None;
|
||||
}
|
||||
let new_span = new_arc_len / arc.radius;
|
||||
|
||||
// Which end (start or end angle) is closer to pick?
|
||||
let curve = crate::entities::curve::arc_curve(arc);
|
||||
let start_pt = Vec3::from_array(curve.point_at(0.0).map(|value| value as f32));
|
||||
let end_pt = Vec3::from_array(curve.point_at(1.0).map(|value| value as f32));
|
||||
let dist_start = (pick_pt - start_pt).length();
|
||||
let dist_end = (pick_pt - end_pt).length();
|
||||
|
||||
let mut result = arc.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
|
||||
if dist_end <= dist_start {
|
||||
// Extend end angle
|
||||
result.end_angle = arc.start_angle + new_span;
|
||||
} else {
|
||||
// Extend start angle (move start backwards)
|
||||
result.start_angle = arc.end_angle - new_span;
|
||||
}
|
||||
Some(EntityType::Arc(result))
|
||||
}
|
||||
|
||||
fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt();
|
||||
if a < 1e-9 {
|
||||
@@ -318,15 +344,7 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option<f64> {
|
||||
LenMode::Delta(d) => Some(current + d),
|
||||
LenMode::Total(t) => Some(*t),
|
||||
LenMode::Percent(p) => Some(current * p / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_span_rad(start: f64, end: f64) -> f64 {
|
||||
let span = (end - start).rem_euclid(TAU);
|
||||
if span < 1e-6 {
|
||||
TAU
|
||||
} else {
|
||||
span
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,3 +454,89 @@ fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<Ent
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["LENGTHEN"] }); // LengthenCommand
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spatial_line() -> EntityType {
|
||||
EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(0.0, 0.0, 4.0),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_delta_edits_offer_one_undo_per_replacement() {
|
||||
let handle = Handle::new(11);
|
||||
let mut command = LengthenCommand::new();
|
||||
assert!(matches!(command.on_text_input("DE 2"), Some(CmdResult::NeedPoint)));
|
||||
|
||||
for _ in 0..2 {
|
||||
command.inject_picked_entity(spatial_line());
|
||||
let CmdResult::ReplaceManyContinue(replacements) =
|
||||
command.on_entity_pick(handle, DVec3::new(0.0, 0.0, 4.0))
|
||||
else {
|
||||
panic!("delta edit must replace the picked line");
|
||||
};
|
||||
let EntityType::Line(line) = &replacements[0].1[0] else {
|
||||
panic!("replacement must stay a line");
|
||||
};
|
||||
assert_eq!(line.end, Vector3::new(0.0, 0.0, 6.0));
|
||||
command.on_entity_replaced(handle, &[]);
|
||||
}
|
||||
|
||||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument)));
|
||||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument)));
|
||||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::NeedPoint)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_line_endpoint_is_projected_onto_its_spatial_support() {
|
||||
let handle = Handle::new(12);
|
||||
let mut command = LengthenCommand::new();
|
||||
assert!(matches!(command.on_text_input("DY"), Some(CmdResult::NeedPoint)));
|
||||
command.inject_picked_entity(spatial_line());
|
||||
assert!(matches!(
|
||||
command.on_entity_pick(handle, DVec3::new(0.0, 0.0, 4.0)),
|
||||
CmdResult::NeedPoint
|
||||
));
|
||||
|
||||
let CmdResult::ReplaceManyContinue(replacements) =
|
||||
command.on_point(DVec3::new(2.0, -3.0, 7.0))
|
||||
else {
|
||||
panic!("dynamic point must replace the line");
|
||||
};
|
||||
let EntityType::Line(line) = &replacements[0].1[0] else {
|
||||
panic!("replacement must stay a line");
|
||||
};
|
||||
assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0));
|
||||
assert_eq!(line.end, Vector3::new(0.0, 0.0, 7.0));
|
||||
assert!(command.needs_entity_pick());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_arc_accepts_total_angle_in_radians() {
|
||||
let arc = acadrust::entities::Arc::from_center_radius_angles(
|
||||
Vector3::ZERO,
|
||||
2.0,
|
||||
0.0,
|
||||
std::f64::consts::FRAC_PI_2,
|
||||
);
|
||||
let EntityType::Arc(result) = lengthen_entity_precise(
|
||||
&EntityType::Arc(arc),
|
||||
DVec3::new(0.0, 2.0, 0.0),
|
||||
&LenMode::TotalAngle(std::f64::consts::PI),
|
||||
)
|
||||
.expect("valid angular total")
|
||||
else {
|
||||
panic!("replacement must stay an arc");
|
||||
};
|
||||
assert!((result.end_angle - result.start_angle - std::f64::consts::PI).abs() < 1.0e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_total_angle_is_a_half_turn_in_degrees() {
|
||||
assert_eq!(LENGTHEN_DEFAULTS.lock().unwrap().total_angle, 180.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user