Files
OneCAD-Draft/src/modules/draw/modify/lengthen.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:34:23 +03:00

473 lines
21 KiB
Rust

// LENGTHEN command — extend or trim a Line or Arc by a specified delta or total.
//
// 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%)
//
// The entity is modified at whichever end is closest to the pick point.
use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs};
use codec::entities::{
Spline as SplineEnt,
};
use kernel::geom2d::Curve;
use codec::types::Vector3;
use codec::{EntityType, Handle};
use glam::{DVec3, Vec3};
use crate::t;
use crate::command::{CadCommand, CmdResult};
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 {
ChooseMode,
Value(ValueMode),
Apply(LenMode),
DynamicPick,
DynamicPoint { handle: Handle, entity: EntityType, pick: DVec3 },
}
impl LengthenCommand {
pub fn new() -> Self {
Self { state: LenState::ChooseMode, picked: None, measurement: None, edits: 0 }
}
}
impl CadCommand for LengthenCommand {
fn name(&self) -> &'static str { "LENGTHEN" }
fn prompt(&self) -> String {
match &self.state {
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 <{}>:",
crate::entities::common::format_angle(
LENGTHEN_DEFAULTS.lock().unwrap().delta_angle.to_radians(),
)
),
LenState::Value(ValueMode::TotalAngle) => format!(
"LENGTHEN Enter total angle <{}>:",
crate::entities::common::format_angle(
LENGTHEN_DEFAULTS.lock().unwrap().total_angle.to_radians(),
)
),
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 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(),
}
}
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(_) | EntityType::Ellipse(_)) => {
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::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> {
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 angle_mode = matches!(mode, ValueMode::DeltaAngle | ValueMode::TotalAngle);
let parsed = if angle_mode {
crate::entities::common::parse_typed_angle(&upper).map(f64::to_degrees)
} else {
upper.replace(',', ".").parse::<f64>().ok()
};
let Some(value) = parsed.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 {
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 {
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 }
};
let text = if matches!(mode, ValueMode::DeltaAngle | ValueMode::TotalAngle) {
format!("{value}d")
} else {
value.to_string()
};
self.on_text_input(&text).unwrap_or(CmdResult::NeedPoint)
}
_ => CmdResult::Cancel,
}
}
}
// ── Mode enum (also used in CmdResult) ────────────────────────────────────
#[derive(Clone)]
pub enum LenMode {
Delta(f64),
Total(f64),
Percent(f64),
DeltaAngle(f64),
TotalAngle(f64),
Dynamic(DVec3),
}
// ── Geometry ───────────────────────────────────────────────────────────────
/// 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 kernel::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(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(ellipse) => {
let curve = crate::entities::curve::ellipse_curve(ellipse)?;
let (start, end) = kernel::space::lengthen::lengthen_ellipse(&curve, pick.to_array(), change)?;
let mut result = ellipse.clone();
result.start_parameter = start;
result.end_parameter = end;
Some(EntityType::Ellipse(result))
}
EntityType::Spline(s) => lengthen_spline(s, pick.as_vec3(), mode),
EntityType::LwPolyline(polyline) => {
let curve = crate::entities::curve::lwpolyline_curve(polyline)?;
let vertices = kernel::space::lengthen::lengthen_polyline(&curve, pick.to_array(), change)?;
let mut result = polyline.clone();
result.vertices = vertices.into_iter().map(|vertex| {
let mut value = polyline.vertices[vertex.source].clone();
value.location.x = vertex.position[0];
value.location.y = vertex.position[1];
value.bulge = vertex.bulge;
let width_delta = value.end_width - value.start_width;
value.end_width = value.start_width + width_delta * vertex.end_fraction;
value.start_width += width_delta * vertex.start_fraction;
value
}).collect();
if result.vertices.iter().any(|v| !v.start_width.is_finite() || !v.end_width.is_finite()
|| v.start_width < 0.0 || v.end_width < 0.0) { return None; }
Some(EntityType::LwPolyline(result))
}
_ => None,
}
}
fn apply_mode(current: f64, mode: &LenMode) -> Option<f64> {
match mode {
LenMode::Delta(d) => Some(current + d),
LenMode::Total(t) => Some(*t),
LenMode::Percent(p) => Some(current * p / 100.0),
_ => None,
}
}
fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let nurbs = spline_to_nurbs(spl)?;
let (t0, t1) = nurbs.domain();
if (t1 - t0).abs() < 1e-12 {
return None;
}
let curve = Curve::Nurbs(nurbs.clone());
let arc_len = curve.length();
if arc_len < 1e-10 {
return None;
}
let new_len = apply_mode(arc_len, mode)?;
if new_len < 1e-10 || new_len >= arc_len {
// A spline is shortened by splitting it, so there is nothing to keep
// if the new length is the whole of it or more. Extending would mean
// continuing the curve past its own control polygon, which is a
// different operation from cutting one.
return None;
}
let p_start = nurbs.point_at_knot(t0);
let p_end = nurbs.point_at_knot(t1);
let (px, py) = (pick_pt.x as f64, pick_pt.y as f64);
let extend_end = (p_end[0] - px).hypot(p_end[1] - py)
<= (p_start[0] - px).hypot(p_start[1] - py);
// Where to cut, by distance along the curve rather than by a bisection
// over repeated chord sums. Keeping the head means cutting `new_len` from
// the start; keeping the tail means cutting what is left over.
let along = if extend_end {
new_len
} else {
arc_len - new_len
};
let at = curve.parameter_at_distance(along);
let cut = (t0 + at * (t1 - t0)).clamp(t0 + 1e-10, t1 - 1e-10);
let (left, right) = spline_cut(spl, cut)?;
Some(EntityType::Spline(if extend_end { left } else { right }))
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["LENGTHEN"] }); // LengthenCommand
#[cfg(test)]
mod tests {
use super::*;
fn spatial_line() -> EntityType {
EntityType::Line(codec::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 = codec::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);
}
#[test]
fn polyline_lengthening_preserves_plane_and_interpolates_widths() {
let mut polyline = codec::entities::LwPolyline::new();
polyline.normal = Vector3::new(0.0, 1.0, 0.0);
polyline.elevation = 5.0;
let mut start = codec::entities::LwVertex::from_coords(0.0, 0.0);
start.start_width = 2.0;
start.end_width = 4.0;
polyline.vertices = vec![start, codec::entities::LwVertex::from_coords(10.0, 0.0)];
let entity = EntityType::LwPolyline(polyline.clone());
let pick = DVec3::from_array(crate::entities::curve::lwpolyline_curve(&polyline)
.unwrap().point_at(1.0));
let EntityType::LwPolyline(result) = lengthen_entity_precise(
&entity,
pick,
&LenMode::Total(15.0),
).unwrap() else { panic!("expected polyline") };
assert_eq!(result.normal, polyline.normal);
assert_eq!(result.elevation, polyline.elevation);
assert!((result.vertices.last().unwrap().location.x - 15.0).abs() < 1e-12);
assert!((result.vertices[0].start_width - 2.0).abs() < 1e-12);
assert!((result.vertices[0].end_width - 5.0).abs() < 1e-12);
}
}