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: &[],
+}