From 306d998ebfbc61e1052f60c3fbd9485e98aaabcc Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:06 +0300 Subject: [PATCH] feat: expose Spline CV in the Draw expansion --- assets/icons/spline_cv.svg | 3 + src/app/commands/draw.rs | 8 +- src/modules/draw/draw/spline.rs | 125 ++++++++++++++++------- src/ui/ribbon/draw_tools/02_spline_cv.rs | 6 ++ 4 files changed, 103 insertions(+), 39 deletions(-) create mode 100644 assets/icons/spline_cv.svg create mode 100644 src/ui/ribbon/draw_tools/02_spline_cv.rs diff --git a/assets/icons/spline_cv.svg b/assets/icons/spline_cv.svg new file mode 100644 index 000000000..e26e9c610 --- /dev/null +++ b/assets/icons/spline_cv.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6807abfa1..34d92e994 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -872,9 +872,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "SPLINE" => { + "SPLINE" | "SPLINECV" => { use crate::modules::draw::draw::spline::SplineCommand; - let new_cmd = SplineCommand::new(); + let new_cmd = if cmd == "SPLINECV" { + SplineCommand::control_vertices() + } else { + SplineCommand::new() + }; self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 97405c448..f143b638b 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -24,49 +24,75 @@ pub fn tool() -> ToolDef { pub struct SplineCommand { pts: Vec, + control_vertices: bool, + choosing_method: bool, } impl SplineCommand { pub fn new() -> Self { - Self { pts: Vec::new() } + Self { pts: Vec::new(), control_vertices: false, choosing_method: false } + } + + pub fn control_vertices() -> Self { + Self { control_vertices: true, ..Self::new() } } fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; } - let fit_points = self - .pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(); - let mut spline = Spline { - degree: 3, - fit_points, - ..Default::default() - }; - spline.flags.closed = closed; - Some(EntityType::Spline(spline)) + Some(EntityType::Spline(make_spline(&self.pts, closed, self.control_vertices))) } } -/// Preview through the same fit-point representation that `build()` commits. -fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { +/// Store the chosen construction method directly in the persistent spline. +fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { + let mut points: Vec = pts + .iter() + .map(|p| Vector3::new(p.x, p.y, p.z)) + .collect(); + let mut spline = if control_vertices { + if closed && pts.first() != pts.last() { + points.push(points[0]); + } + let count = points.len(); + let degree = 3.min(count.saturating_sub(1)); + let spans = count - degree; + // Open uniform knot vector: degree + 1 equal knots at each endpoint. + let knots = (0..count + degree + 1) + .map(|index| { + if index <= degree { 0.0 } + else if index >= count { 1.0 } + else { (index - degree) as f64 / spans as f64 } + }) + .collect(); + Spline { + degree: degree as i32, + control_points: points, + knots, + weights: vec![1.0; count], + ..Default::default() + } + } else { + Spline { + degree: 3, + fit_points: points, + ..Default::default() + } + }; + spline.flags.closed = closed; + spline +} + +/// Preview the exact representation that `build()` commits. +fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32; 3]> { if pts.len() < 2 { return pts .iter() .map(|p| [p.x as f32, p.y as f32, p.z as f32]) .collect(); } - let mut spline = Spline { - degree: 3, - fit_points: pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(), - ..Default::default() - }; - spline.flags.closed = closed; + let spline = make_spline(pts, closed, control_vertices); crate::entities::curve::spline_curve(&spline) .map(|curve| crate::entities::curve::curve_points(&curve)) .unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline)) @@ -77,11 +103,15 @@ fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { impl CadCommand for SplineCommand { fn name(&self) -> &'static str { - "SPLINE" + if self.control_vertices { "SPLINECV" } else { "SPLINE" } } fn prompt(&self) -> String { - if self.pts.is_empty() { + if self.choosing_method { + "SPLINE Choose creation method [Fit/Control vertices]:".into() + } else if self.pts.is_empty() && self.control_vertices { + t!("SPLINE Specify first control point:").into_owned() + } else if self.pts.is_empty() { t!("SPLINE Specify first point:").into_owned() } else { let n = self.pts.len(); @@ -91,8 +121,11 @@ impl CadCommand for SplineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + if self.choosing_method { + return vec![CmdOption::new("Fit", "FIT"), CmdOption::new("Control vertices", "CV")]; + } if self.pts.is_empty() { - return Vec::new(); + return vec![CmdOption::new(t!("Method").as_ref(), "M")]; } let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")]; // Undo only makes sense once a control point exists. @@ -102,11 +135,18 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.choosing_method || !pt.is_finite() { + return CmdResult::NeedPoint; + } self.pts.push(pt); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { + if self.choosing_method { + self.choosing_method = false; + return CmdResult::NeedPoint; + } match self.build(false) { Some(e) => CmdResult::CommitAndExit(e), None => CmdResult::Cancel, @@ -114,26 +154,37 @@ impl CadCommand for SplineCommand { } fn enter_accepts_default_start(&self) -> bool { - self.pts.is_empty() + self.pts.is_empty() && !self.choosing_method } fn on_escape(&mut self) -> CmdResult { - match self.build(false) { - Some(e) => CmdResult::CommitAndExit(e), - None => CmdResult::Cancel, - } + CmdResult::Cancel } fn wants_text_input(&self) -> bool { - !self.pts.is_empty() + true } fn point_step_accepts_keywords(&self) -> bool { - !self.pts.is_empty() + true } fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { + "M" | "METHOD" if self.pts.is_empty() => { + self.choosing_method = true; + Some(CmdResult::NeedPoint) + } + "F" | "FIT" if self.pts.is_empty() => { + self.control_vertices = false; + self.choosing_method = false; + Some(CmdResult::NeedPoint) + } + "CV" | "CONTROL" | "CONTROLVERTICES" if self.pts.is_empty() => { + self.control_vertices = true; + self.choosing_method = false; + Some(CmdResult::NeedPoint) + } "C" | "CLOSE" => match self.build(true) { Some(e) => Some(CmdResult::CommitAndExit(e)), None => Some(CmdResult::NeedPoint), @@ -150,12 +201,12 @@ impl CadCommand for SplineCommand { if self.pts.is_empty() { return None; } - // Preview the committed fit-point representation. + // Preview the committed construction method. let mut ctrl = self.pts.clone(); ctrl.push(pt); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl, false), + sample_curve(&ctrl, false, self.control_vertices), WireModel::CYAN, false, )) @@ -164,4 +215,4 @@ impl CadCommand for SplineCommand { // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["SPLINE"] }); // SplineCommand +inventory::submit!(crate::command::CommandRegistration { names: &["SPLINE", "SPLINECV"] }); // SplineCommand diff --git a/src/ui/ribbon/draw_tools/02_spline_cv.rs b/src/ui/ribbon/draw_tools/02_spline_cv.rs new file mode 100644 index 000000000..ddb30c5e2 --- /dev/null +++ b/src/ui/ribbon/draw_tools/02_spline_cv.rs @@ -0,0 +1,6 @@ +Tool { + command: "SPLINECV", + label: "Spline CV", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/spline_cv.svg")), + options: &[], +}