From 2d9befc77c85ef1549ac60d50a2f588d76453a40 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:25:12 +0300 Subject: [PATCH 01/13] Add spline refinement and repeatable editing workflows --- src/modules/draw/modify/reverse.rs | 12 +- src/modules/draw/modify/splinedit.rs | 305 +++++++++++++++------------ 2 files changed, 182 insertions(+), 135 deletions(-) diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index 3d7e3d51e..eabd371b5 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -176,9 +176,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities out } -/// Reverse a Spline: flip control points and fit points, regenerate the -/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch. -fn reverse_spline(sp: &Spline) -> Spline { +/// Reverse stored coordinates in 3D and let the kernel mirror the knot domain. +pub(super) fn reverse_spline(sp: &Spline) -> Spline { let mut out = sp.clone(); out.control_points.reverse(); out.fit_points.reverse(); @@ -186,8 +185,11 @@ fn reverse_spline(sp: &Spline) -> Spline { if out.weights.len() == out.control_points.len() { out.weights.reverse(); } - out.knots = - Spline::generate_clamped_knots(out.degree as usize, out.control_points.len()); + if let Some(curve) = super::spline_ops::spline_to_nurbs(sp) { + out.knots = curve.reversed().knots().to_vec(); + } + out.begin_tangent = acadrust::types::Vector3::new(-sp.end_tangent.x, -sp.end_tangent.y, -sp.end_tangent.z); + out.end_tangent = acadrust::types::Vector3::new(-sp.begin_tangent.x, -sp.begin_tangent.y, -sp.begin_tangent.z); out } diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 6a5852f73..828980f97 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -13,7 +13,6 @@ use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; -use crate::modules::draw::modify::spline_ops::spline_to_nurbs; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,138 +28,217 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy)] enum Step { - /// Waiting for the user to pick a spline entity. SelectSpline, - /// Spline selected; waiting for sub-command text input. - SubCommand { handle: acadrust::Handle }, + Options, + Refine, + Add, + Elevate, + Move { index: usize, refine: bool }, + Weight { index: usize }, + SelectVertex { weight: bool, refine: bool }, } pub struct SplineditCommand { step: Step, + handle: acadrust::Handle, + spline: Option, + pending: Option, + history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, } impl SplineditCommand { pub fn new() -> Self { - Self { - step: Step::SelectSpline, + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new() } + } + + fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { + self.pending = Some(spline.clone()); + CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) + } + + fn refined(&self, point: Option, degree: Option) -> Option { + let source = self.spline.as_ref()?; + let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; + let cadkernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else { return None; }; + if let Some(point) = point { + let projected = planar.plane.project([point.x, point.y, point.z])?; + let nearest = cadkernel::geom2d::closest_point(&cadkernel::geom2d::Curve::Nurbs(curve.clone()), projected); + let (start, end) = curve.domain(); + let parameter = start + nearest.t * (end - start); + if parameter <= start || parameter >= end { return None; } + curve.insert_knot(parameter); } + if let Some(degree) = degree { + if degree <= curve.degree() || degree > 26 { return None; } + curve = curve.elevated(degree - curve.degree())?; + } + let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); + result.control_points = curve.control_points().iter().map(|point| { + let world = planar.plane.point_at(*point); + Vector3::new(world[0], world[1], world[2]) + }).collect(); + Some(result) } } impl CadCommand for SplineditCommand { - fn name(&self) -> &'static str { - "SPLINEDIT" - } - + fn name(&self) -> &'static str { "SPLINEDIT" } fn prompt(&self) -> String { - match &self.step { + match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), - Step::SubCommand { .. } => crate::t!("SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Refine => crate::t!("SPLINEDIT [Add/Elevate order/Move/Weight/eXit] :").into_owned(), + Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), + Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)), + Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] :", index + 1), + Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(), + Step::Weight { index } => format!("SPLINEDIT Vertex {}: enter new weight or [Next/Previous/Select point/eXit] :", index + 1), } } - - fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::SelectSpline) + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + Step::Options => vec![CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], + Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], + Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], + _ => Vec::new(), + } + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectSpline) } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { + self.spline = match entity { EntityType::Spline(spline) => Some(spline), _ => None }; } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { - if handle.is_null() { - return CmdResult::NeedPoint; - } - self.step = Step::SubCommand { handle }; + if handle.is_null() || self.spline.is_none() { return CmdResult::NeedPoint; } + self.handle = handle; + self.step = Step::Options; CmdResult::NeedPoint } - - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::SubCommand { .. }) + fn on_entity_replaced(&mut self, old: acadrust::Handle, new: &[acadrust::Handle]) { + if old == self.handle { + if let (Some(&handle), Some(replacement)) = (new.first(), self.pending.take()) { + if let Some(previous) = self.spline.replace(replacement) { self.history.push((old, previous)); } + self.handle = handle; + } + } } - + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { - let Step::SubCommand { handle } = &self.step else { - return None; - }; - let handle = *handle; - match text.trim().to_uppercase().as_str() { - "CLOSE" | "C" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Close.into_marker(handle)], - )), - "OPEN" | "O" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Open.into_marker(handle)], - )), - "REVERSE" | "R" | "REV" => Some(CmdResult::ReplaceEntity( - handle, - vec![SplineOp::Reverse.into_marker(handle)], - )), - "EXIT" | "X" | "" => Some(CmdResult::Cancel), - _ => None, + let upper = text.trim().to_uppercase(); + if upper.is_empty() { return Some(self.on_enter()); } + match self.step { + Step::Options => match upper.as_str() { + "R" | "REFINE" => self.step = Step::Refine, + "M" | "MOVE" => self.step = Step::Move { index: 0, refine: false }, + "X" | "EXIT" => return Some(CmdResult::Cancel), + "U" | "UNDO" => { + if let Some((handle, spline)) = self.history.pop() { + self.handle = handle; + self.spline = Some(spline); + return Some(CmdResult::UndoDocument); + } + } + "C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => { + let mut spline = self.spline.clone()?; + let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; + apply_to_spline(&mut spline, op); + return Some(self.replace(spline)); + } + _ => {} + }, + Step::Refine => match upper.as_str() { + "A" | "ADD" => self.step = Step::Add, + "E" | "ELEVATE" => self.step = Step::Elevate, + "M" | "MOVE" => self.step = Step::Move { index: 0, refine: true }, + "W" | "WEIGHT" => self.step = Step::Weight { index: 0 }, + "X" | "EXIT" => self.step = Step::Options, + _ => {} + }, + Step::Elevate => { + if let Some(spline) = upper.parse::().ok().and_then(|degree| self.refined(None, Some(degree))) { + self.step = Step::Refine; + return Some(self.replace(spline)); + } + } + Step::Move { index, .. } | Step::Weight { index } => { + let count = self.spline.as_ref().map_or(0, |s| s.control_points.len()); + if count == 0 { return Some(CmdResult::NeedPoint); } + let next = match upper.as_str() { "N" | "NEXT" => Some((index + 1) % count), "P" | "PREVIOUS" => Some((index + count - 1) % count), _ => None }; + if let Some(next) = next { + self.step = match self.step { Step::Move { refine, .. } => Step::Move { index: next, refine }, _ => Step::Weight { index: next } }; + } else if matches!(upper.as_str(), "S" | "SELECT") { + self.step = match self.step { + Step::Move { refine, .. } => Step::SelectVertex { weight: false, refine }, + _ => Step::SelectVertex { weight: true, refine: true }, + }; + } else if matches!(upper.as_str(), "X" | "EXIT") { + self.step = match self.step { Step::Move { refine: false, .. } => Step::Options, _ => Step::Refine }; + } else if matches!(self.step, Step::Weight { .. }) { + if let Some(weight) = upper.replace(',', ".").parse::().ok().filter(|weight| weight.is_finite() && *weight > 0.0) { + let mut spline = self.spline.clone()?; + spline.weights.resize(count, 1.0); + spline.weights[index] = weight; + spline.flags.rational = true; + spline.fit_points.clear(); + return Some(self.replace(spline)); + } + } + } + _ => {} } + Some(CmdResult::NeedPoint) } - - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !point.is_finite() { return CmdResult::NeedPoint; } + match self.step { + Step::SelectVertex { weight, refine } => { + if let Some(index) = self.spline.as_ref().and_then(|spline| spline.control_points.iter().enumerate() + .min_by(|(_, a), (_, b)| { + let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); + distance(a).total_cmp(&distance(b)) + }).map(|(index, _)| index)) { + self.step = if weight { Step::Weight { index } } else { Step::Move { index, refine } }; + } + CmdResult::NeedPoint + } + Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), + Step::Move { index, .. } => { + let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; }; + let Some(vertex) = spline.control_points.get_mut(index) else { return CmdResult::NeedPoint; }; + *vertex = Vector3::new(point.x, point.y, point.z); + spline.fit_points.clear(); + self.replace(spline) + } + _ => CmdResult::NeedPoint, + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } - fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - vec![] + match self.step { + Step::SelectSpline | Step::Options => CmdResult::Cancel, + Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } + Step::Add | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } + Step::Elevate => { + let degree = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); + self.on_text_input(°ree.to_string()).unwrap_or(CmdResult::NeedPoint) + } + Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), + } } + fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { vec![] } } - -// ── Spline operation helpers ─────────────────────────────────────────────── -// -// Because `CmdResult::ReplaceEntity` takes `Vec` and we need the -// host to apply it to the actual document, we use a small "deferred op" trick: -// the host applies the spline transform in `apply_cmd_result` by detecting -// that we replaced an entity with zero new entities (delete) or one new one. -// -// Here we actually build the modified entity without accessing the document — -// we only have the handle. The real transformation (close/open/reverse) is -// applied in the Scene via `apply_spline_op`. - -enum SplineOp { - Close, - Open, - Reverse, -} - -impl SplineOp { - /// Return a placeholder that encodes the op in a comment field. - /// The host `cmd_result.rs` detects SPLINEDIT replaces and calls - /// `Scene::apply_spline_op`. - fn into_marker(self, _handle: acadrust::Handle) -> EntityType { - // We can't build the modified spline here without the document. - // Return a sentinel — the actual transformation is handled by the - // apply_spline_op path in Scene. We use an XLine as a sentinel - // that the host will never actually commit (it detects the SPLINEDIT - // replace path and calls apply_spline_op instead). - // - // In practice: use ReplaceMany with empty new entities + a SplineOp - // side-channel via the xattach_path mechanism isn't clean. - // - // Simpler: encode op in the sentinel entity's layer field. - let mut sentinel = acadrust::entities::XLine::new( - acadrust::types::Vector3::zero(), - acadrust::types::Vector3::new(1.0, 0.0, 0.0), - ); - sentinel.common.layer = match self { - SplineOp::Close => "__SPLINEDIT_CLOSE__".to_string(), - SplineOp::Open => "__SPLINEDIT_OPEN__".to_string(), - SplineOp::Reverse => "__SPLINEDIT_REVERSE__".to_string(), - }; - EntityType::XLine(sentinel) - } -} - /// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity. /// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected. pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, op: &str) { let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else { return; }; + apply_to_spline(spline, op); +} + +fn apply_to_spline(spline: &mut acadrust::entities::Spline, op: &str) { match op { "__SPLINEDIT_CLOSE__" => { if spline.control_points.len() >= 2 { @@ -193,40 +271,7 @@ pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle } } "__SPLINEDIT_REVERSE__" => { - spline.fit_points.reverse(); - let begin = spline.begin_tangent; - spline.begin_tangent = spline.end_tangent; - spline.end_tangent = begin; - // The knots mirror within the domain rather than being - // regenerated. Rebuilding a clamped *uniform* vector here changed - // the shape of any spline whose knots were unevenly spaced — - // which is most of them once a modeller has been near one. - match spline_to_nurbs(spline).map(|curve| curve.reversed()) { - Some(reversed) => { - let elevation = spline - .control_points - .first() - .or_else(|| spline.fit_points.first()) - .map(|p| p.z) - .unwrap_or(0.0); - spline.degree = reversed.degree() as i32; - spline.knots = reversed.knots().to_vec(); - spline.control_points = reversed - .control_points() - .iter() - .map(|p| Vector3::new(p[0], p[1], elevation)) - .collect(); - spline.weights = if reversed.is_rational() { - reversed.weights().to_vec() - } else { - Vec::new() - }; - } - // Nothing the kernel can read — a spline with too few points - // to describe a curve. Reversing its stored points is still - // the honest answer. - None => spline.control_points.reverse(), - } + *spline = super::reverse::reverse_spline(spline); } _ => {} } From a04f13f84a86bfb494364928fec708553b1f16e9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:55:23 +0300 Subject: [PATCH 02/13] Initialize spline editing from a single unlocked preselected spline --- src/app/commands/inquiry.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 08f920696..7d98772cd 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -329,7 +329,17 @@ impl OpenCADStudio { "SPLINEDIT" => { use crate::modules::draw::modify::splinedit::SplineditCommand; - let cmd_obj = SplineditCommand::new(); + let mut cmd_obj = SplineditCommand::new(); + let selected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + if let [handle] = selected.as_slice() { + if let Some(entity @ acadrust::EntityType::Spline(_)) = + self.tabs[i].scene.document.get_entity(*handle).cloned() + { + if self.reject_locked_edit(i, *handle) { return Some(Task::none()); } + cmd_obj.inject_picked_entity(entity); + cmd_obj.on_entity_pick(*handle, glam::DVec3::ZERO); + } + } self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } From abab89e20693ddd5cfc3691dd45dc7b282eaeab8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:05:10 +0300 Subject: [PATCH 03/13] Delete spline control vertices through the Refine submenu --- src/modules/draw/modify/splinedit.rs | 31 ++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 828980f97..0007d0402 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -34,6 +34,7 @@ enum Step { Options, Refine, Add, + Delete, Elevate, Move { index: usize, refine: bool }, Weight { index: usize }, @@ -89,8 +90,9 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), Step::Options => crate::t!("SPLINEDIT [Close/Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), - Step::Refine => crate::t!("SPLINEDIT [Add/Elevate order/Move/Weight/eXit] :").into_owned(), + Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), + Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)), Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] :", index + 1), Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(), @@ -101,7 +103,7 @@ impl CadCommand for SplineditCommand { use crate::command::CmdOption; match self.step { Step::Options => vec![CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], - Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], + Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], _ => Vec::new(), } @@ -125,7 +127,7 @@ impl CadCommand for SplineditCommand { } } } - fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Move { .. } | Step::SelectVertex { .. }) } + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { let upper = text.trim().to_uppercase(); if upper.is_empty() { return Some(self.on_enter()); } @@ -151,6 +153,7 @@ impl CadCommand for SplineditCommand { }, Step::Refine => match upper.as_str() { "A" | "ADD" => self.step = Step::Add, + "D" | "DELETE" => self.step = Step::Delete, "E" | "ELEVATE" => self.step = Step::Elevate, "M" | "MOVE" => self.step = Step::Move { index: 0, refine: true }, "W" | "WEIGHT" => self.step = Step::Weight { index: 0 }, @@ -204,6 +207,26 @@ impl CadCommand for SplineditCommand { } CmdResult::NeedPoint } + Step::Delete => { + let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; + let Some(index) = source.control_points.iter().enumerate().min_by(|(_, a), (_, b)| { + let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); + distance(a).total_cmp(&distance(b)) + }).map(|(index, _)| index) else { return CmdResult::NeedPoint; }; + let controls = source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } + else { source.weights.clone() }; + let curve = cadkernel::space::NurbsCurve3::new_strict(source.degree as usize, controls, + source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic)); + let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index)) else { return CmdResult::NeedPoint; }; + let mut spline = source.clone(); + spline.degree = curve.degree() as i32; + spline.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + spline.weights = curve.weights().to_vec(); + spline.knots = curve.knots().to_vec(); + spline.fit_points.clear(); + self.replace(spline) + } Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), Step::Move { index, .. } => { let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; }; @@ -219,7 +242,7 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline | Step::Options => CmdResult::Cancel, Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } - Step::Add | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } + Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { let degree = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); self.on_text_input(°ree.to_string()).unwrap_or(CmdResult::NeedPoint) From 36b799877321a521fc31c6797e428425d8f1d3ba Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:05:52 +0300 Subject: [PATCH 04/13] Preserve unsupported closed spline deletion state --- src/modules/draw/modify/splinedit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 0007d0402..663adf959 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -217,7 +217,7 @@ impl CadCommand for SplineditCommand { let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; let curve = cadkernel::space::NurbsCurve3::new_strict(source.degree as usize, controls, - source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic)); + source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic || source.flags.closed)); let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index)) else { return CmdResult::NeedPoint; }; let mut spline = source.clone(); spline.degree = curve.degree() as i32; From 12c661d11867214aae1be74d12eaafe26d4b90a9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:07:49 +0300 Subject: [PATCH 05/13] Refresh spline planarity after control vertex deletion --- src/modules/draw/modify/splinedit.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 663adf959..e17c0da1a 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -225,6 +225,7 @@ impl CadCommand for SplineditCommand { spline.weights = curve.weights().to_vec(); spline.knots = curve.knots().to_vec(); spline.fit_points.clear(); + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); self.replace(spline) } Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)), From 42439c2e501c2ca5bbef49f4d9049d65897fcccf Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:13:09 +0300 Subject: [PATCH 06/13] Pick spline control vertices with the configured screen aperture --- src/app/command_driver.rs | 28 ++++++++++++++++++++ src/app/update/viewport.rs | 2 ++ src/command.rs | 15 +++++++++++ src/modules/draw/modify/splinedit.rs | 39 +++++++++++++++++++++------- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..8b80557e9 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -312,6 +312,33 @@ impl OpenCADStudio { true } + /// Refresh screen-space feature picking immediately before point dispatch. + pub(super) fn refresh_command_point_pick_context(&mut self, i: usize) { + if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.wants_point_pick_context()) + { + let scene = &self.tabs[i].scene; + let size = scene.selection.borrow().vp_size; + let edit = scene.viewport_edit_frame(size); + let tile = edit.as_ref().map(|(_, rect)| *rect) + .unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1)); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: tile.width, height: tile.height }; + let context = if bounds.width > 0.0 && bounds.height > 0.0 { + let (view, eye) = if let Some((camera, _)) = edit { + (camera.view_proj_rte(bounds), camera.eye()) + } else { + let camera = scene.camera.borrow(); + (camera.view_proj_rte(bounds), camera.eye()) + }; + Some(crate::command::PointPickContext { view, eye, bounds, + aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box) }) + } else { None }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_point_pick_context(context); + } + } + } + /// Drive the active command's step machine with one [`StepInput`], then /// apply the result. This is the single entry point every input source /// (command line, headless, dynamic input, plugin API, viewport) funnels @@ -414,6 +441,7 @@ impl OpenCADStudio { command.inject_selection_entities(entities); } } + if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 606ffa871..8dee50e81 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3887,6 +3887,7 @@ impl OpenCADStudio { } else if self.tabs[i].active_cmd.as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { + self.refresh_command_point_pick_context(i); self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd @@ -3963,6 +3964,7 @@ impl OpenCADStudio { // (LINE tangent to two circles, which needs both). When // it does, sync last_point to the command's resolved // anchor since it replaced the picked coordinate. + self.refresh_command_point_pick_context(i); let handled = self.tabs[i] .active_cmd .as_mut() diff --git a/src/command.rs b/src/command.rs index d69859462..177d25894 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1939,6 +1939,15 @@ impl InputKind { } } +/// Current screen projection and configured aperture for point-feature picking. +#[derive(Clone, Copy)] +pub struct PointPickContext { + pub view: glam::Mat4, + pub eye: DVec3, + pub bounds: iced::Rectangle, + pub aperture_px: f32, +} + pub trait CadCommand: Send { /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. @@ -2006,6 +2015,12 @@ pub trait CadCommand: Send { /// Called when the user left-clicks in the viewport (point pick). fn on_point(&mut self, pt: DVec3) -> CmdResult; + /// Opt in only while a point input selects an existing point feature. + fn wants_point_pick_context(&self) -> bool { false } + + /// Refreshed for each point input so zoom and viewport changes are reflected. + fn set_point_pick_context(&mut self, _context: Option) {} + /// Called when the user presses Enter (finalize / next option). fn on_enter(&mut self) -> CmdResult; diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index e17c0da1a..379620e27 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -47,11 +47,31 @@ pub struct SplineditCommand { spline: Option, pending: Option, history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, + pick_context: Option, } impl SplineditCommand { pub fn new() -> Self { - Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new() } + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None } + } + + fn picked_vertex(&self, point: DVec3) -> Option { + let context = self.pick_context?; + let project = |point: DVec3| { + let clip = context.view * (point - context.eye).as_vec3().extend(1.0); + if !clip.is_finite() || clip.w <= 0.0 { return None; } + let screen = crate::scene::pick::hit_test::world_to_screen( + point, context.view, context.eye, context.bounds); + (screen.x.is_finite() && screen.y.is_finite() + && screen.x >= 0.0 && screen.x <= context.bounds.width + && screen.y >= 0.0 && screen.y <= context.bounds.height).then_some(screen) + }; + let cursor = project(point)?; + self.spline.as_ref()?.control_points.iter().enumerate().filter_map(|(index, vertex)| { + let screen = project(DVec3::new(vertex.x, vertex.y, vertex.z))?; + let distance = (screen.x - cursor.x).hypot(screen.y - cursor.y); + (distance.is_finite() && distance <= context.aperture_px).then_some((index, distance)) + }).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(index, _)| index) } fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { @@ -194,25 +214,24 @@ impl CadCommand for SplineditCommand { } Some(CmdResult::NeedPoint) } + fn wants_point_pick_context(&self) -> bool { + matches!(self.step, Step::Delete | Step::SelectVertex { .. }) + } + fn set_point_pick_context(&mut self, context: Option) { + self.pick_context = context; + } fn on_point(&mut self, point: DVec3) -> CmdResult { if !point.is_finite() { return CmdResult::NeedPoint; } match self.step { Step::SelectVertex { weight, refine } => { - if let Some(index) = self.spline.as_ref().and_then(|spline| spline.control_points.iter().enumerate() - .min_by(|(_, a), (_, b)| { - let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); - distance(a).total_cmp(&distance(b)) - }).map(|(index, _)| index)) { + if let Some(index) = self.picked_vertex(point) { self.step = if weight { Step::Weight { index } } else { Step::Move { index, refine } }; } CmdResult::NeedPoint } Step::Delete => { let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; - let Some(index) = source.control_points.iter().enumerate().min_by(|(_, a), (_, b)| { - let distance = |p: &Vector3| point.distance_squared(DVec3::new(p.x, p.y, p.z)); - distance(a).total_cmp(&distance(b)) - }).map(|(index, _)| index) else { return CmdResult::NeedPoint; }; + let Some(index) = self.picked_vertex(point) else { return CmdResult::NeedPoint; }; let controls = source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(); let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; From 8012fc7b452b40c66f5df624c3bcf6f6b69e3722 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:35:57 +0300 Subject: [PATCH 07/13] Interpret spline elevation input as order rather than degree --- src/modules/draw/modify/splinedit.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 379620e27..6f378250f 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -92,7 +92,7 @@ impl SplineditCommand { curve.insert_knot(parameter); } if let Some(degree) = degree { - if degree <= curve.degree() || degree > 26 { return None; } + if degree <= curve.degree() || degree > 25 { return None; } curve = curve.elevated(degree - curve.degree())?; } let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); @@ -181,7 +181,13 @@ impl CadCommand for SplineditCommand { _ => {} }, Step::Elevate => { - if let Some(spline) = upper.parse::().ok().and_then(|degree| self.refined(None, Some(degree))) { + let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1; + let order = upper.parse::().ok().filter(|order| *order >= current_order && *order <= 26)?; + if order == current_order { + self.step = Step::Refine; + return Some(CmdResult::NeedPoint); + } + if let Some(spline) = self.refined(None, Some(order - 1)) { self.step = Step::Refine; return Some(self.replace(spline)); } @@ -264,8 +270,8 @@ impl CadCommand for SplineditCommand { Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { - let degree = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); - self.on_text_input(°ree.to_string()).unwrap_or(CmdResult::NeedPoint) + let order = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1); + self.on_text_input(&order.to_string()).unwrap_or(CmdResult::NeedPoint) } Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), } From 1bfbd67b7bda726e5b0cc48a4d14f071b51ddcc2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:39:13 +0300 Subject: [PATCH 08/13] Show spline closure options only for the applicable state --- src/modules/draw/modify/splinedit.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 6f378250f..8e30e761f 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -74,6 +74,10 @@ impl SplineditCommand { }).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(index, _)| index) } + fn closed(&self) -> bool { + self.spline.as_ref().is_some_and(|spline| spline.flags.closed || spline.flags.periodic) + } + fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult { self.pending = Some(spline.clone()); CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) @@ -109,7 +113,8 @@ impl CadCommand for SplineditCommand { fn prompt(&self) -> String { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), - Step::Options => crate::t!("SPLINEDIT [Close/Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), @@ -122,7 +127,7 @@ impl CadCommand for SplineditCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match self.step { - Step::Options => vec![CmdOption::new("Close", "C"), CmdOption::new("Open", "O"), CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], + Step::Options => vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }, CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], _ => Vec::new(), @@ -164,6 +169,10 @@ impl CadCommand for SplineditCommand { } } "C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => { + if (matches!(upper.as_str(), "C" | "CLOSE") && self.closed()) + || (matches!(upper.as_str(), "O" | "OPEN") && !self.closed()) { + return Some(CmdResult::NeedPoint); + } let mut spline = self.spline.clone()?; let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; apply_to_spline(&mut spline, op); From b51672470dc3a3d63b6d7f2d53b32165a7abba04 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:50:22 +0300 Subject: [PATCH 09/13] Preserve spline construction through closure and reopening --- src/modules/draw/modify/splinedit.rs | 101 +++++++++++++++++---------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 8e30e761f..c549aba05 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -176,6 +176,7 @@ impl CadCommand for SplineditCommand { let mut spline = self.spline.clone()?; let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" }; apply_to_spline(&mut spline, op); + if self.spline.as_ref() == Some(&spline) { return Some(CmdResult::NeedPoint); } return Some(self.replace(spline)); } _ => {} @@ -297,44 +298,72 @@ pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle } fn apply_to_spline(spline: &mut acadrust::entities::Spline, op: &str) { - match op { - "__SPLINEDIT_CLOSE__" => { - if spline.control_points.len() >= 2 { - let first = spline.control_points[0]; - spline.control_points.push(first); - // Regenerate clamped knots. - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); - spline.fit_points.clear(); - } - } - "__SPLINEDIT_OPEN__" => { - if spline.control_points.len() >= 2 { - let n = spline.control_points.len(); - let first = spline.control_points[0]; - let last = spline.control_points[n - 1]; - if (first.x - last.x).abs() < 1e-9 - && (first.y - last.y).abs() < 1e-9 - && (first.z - last.z).abs() < 1e-9 - { - spline.control_points.pop(); - spline.knots = acadrust::entities::Spline::generate_clamped_knots( - spline.degree as usize, - spline.control_points.len(), - ); - spline.fit_points.clear(); - } - } - } - "__SPLINEDIT_REVERSE__" => { - *spline = super::reverse::reverse_spline(spline); - } - _ => {} - } + let result = match op { + "__SPLINEDIT_CLOSE__" => change_closure(spline, true), + "__SPLINEDIT_OPEN__" => change_closure(spline, false), + "__SPLINEDIT_REVERSE__" => Some(super::reverse::reverse_spline(spline)), + _ => None, + }; + if let Some(result) = result { *spline = result; } } +fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option { + use cadkernel::space::{NurbsCurve3, Parameterization}; + if (source.flags.closed || source.flags.periodic) == closed { return None; } + let fit_method = !source.fit_points.is_empty() || source.dwg_flags1 & 1 != 0 || source.dxf_flags & 32 != 0; + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let xyz = |point: &Vector3| [point.x, point.y, point.z]; + let controls: Vec<_> = source.control_points.iter().map(xyz).collect(); + let degree = usize::try_from(source.degree).ok()?; + let weights = if source.weights.is_empty() { vec![1.0; controls.len()] } else { source.weights.clone() }; + if fit_method && weights.windows(2).any(|pair| pair[0] != pair[1]) { return None; } + let stored_curve = || NurbsCurve3::new_strict(degree, controls.clone(), source.knots.clone(), weights.clone()); + let mut fit_points = Vec::new(); + let curve = if fit_method { + fit_points = source.fit_points.iter().map(xyz).collect(); + if fit_points.is_empty() { + let curve = stored_curve()?; + let (start, end) = curve.domain(); + let mut parameters: Vec<_> = curve.knots().iter().copied() + .filter(|parameter| *parameter >= start && if closed { *parameter <= end } else { *parameter < end }).collect(); + parameters.dedup(); + fit_points = parameters.into_iter().map(|parameter| curve.point_at_knot(parameter)).collect(); + } + let curve = if closed { + NurbsCurve3::interpolate_periodic(&fit_points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&fit_points, None, None, parameterization)? + }; + curve.compact_knots(source.control_tolerance.max(1e-9))? + } else if closed { + NurbsCurve3::from_weighted_control_polygon(degree, &controls, &weights, true)? + } else { + let curve = stored_curve()?; + curve.without_control_vertex(controls.len().checked_sub(1)?)? + }; + let mut result = source.clone(); + result.degree = curve.degree() as i32; + result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + result.knots = curve.knots().to_vec(); + result.weights = curve.weights().to_vec(); + result.flags.closed = closed; + result.flags.periodic = closed; + if closed { result.dxf_flags |= 2048; } else { result.dxf_flags &= !2048; } + result.flags.rational = if fit_method { false } else { source.flags.rational || curve.is_rational() }; + result.fit_points = if !closed && fit_method { + fit_points.iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect() + } else { Vec::new() }; + if fit_method { + result.dwg_flags1 |= 1; + result.dxf_flags |= 32 | 1024; + result.begin_tangent = Vector3::ZERO; + result.end_tangent = Vector3::ZERO; + } + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + Some(result) +} // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand From 57c9e93d0c7d5bedb275713a08a162aa9755697b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:04:54 +0300 Subject: [PATCH 10/13] Join compatible curves during spline editing with grouped undo --- src/app/command_driver.rs | 3 ++ src/command.rs | 3 ++ src/modules/draw/modify/splinedit.rs | 52 +++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8b80557e9..16d57ae5f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -417,10 +417,13 @@ impl OpenCADStudio { } } if let StepInput::SelectionComplete(handles) = &input { + let exclude_locked = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.selection_entities_exclude_locked()); let entities = { let scene = &self.tabs[i].scene; handles .iter() + .filter(|handle| !exclude_locked || !scene.is_layer_locked(**handle)) .filter_map(|handle| { scene.document.get_entity(*handle).cloned().map(|entity| { let surface_area = scene diff --git a/src/command.rs b/src/command.rs index 177d25894..919c869e4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2301,6 +2301,9 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Exclude locked-layer entities from injected selection geometry. + fn selection_entities_exclude_locked(&self) -> bool { false } + fn inject_selection_entities(&mut self, _entities: Vec) {} fn area_preview_regions(&self) -> Option> { diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index c549aba05..5e260b414 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -33,6 +33,7 @@ enum Step { SelectSpline, Options, Refine, + Join, Add, Delete, Elevate, @@ -48,11 +49,12 @@ pub struct SplineditCommand { pending: Option, history: Vec<(acadrust::Handle, acadrust::entities::Spline)>, pick_context: Option, + join_candidates: Vec, } impl SplineditCommand { pub fn new() -> Self { - Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None } + Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new() } } fn picked_vertex(&self, point: DVec3) -> Option { @@ -83,6 +85,24 @@ impl SplineditCommand { CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])]) } + fn finish_join(&mut self) -> CmdResult { + self.step = Step::Options; + let candidates = std::mem::take(&mut self.join_candidates); + let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; }; + let selected: Vec<_> = candidates.iter().filter(|item| item.handle != self.handle) + .map(|item| (item.handle, &item.entity)).collect(); + let Some((EntityType::Spline(mut spline), consumed)) = super::join::join_to_source( + &EntityType::Spline(source.clone()), &selected) else { return CmdResult::NeedPoint; }; + spline.dwg_flags1 &= !1; + spline.dxf_flags &= !(32 | 1024); + self.pending = Some(spline.clone()); + let mut replacements = vec![(self.handle, vec![EntityType::Spline(spline)])]; + replacements.extend(consumed.into_iter().map(|handle| (handle, Vec::new()))); + // The host stores one document snapshot, including every consumed curve. + // The existing command-local Undo restores that snapshot and the source cache. + CmdResult::ReplaceManyContinue(replacements) + } + fn refined(&self, point: Option, degree: Option) -> Option { let source = self.spline.as_ref()?; let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; @@ -114,7 +134,8 @@ impl CadCommand for SplineditCommand { match self.step { Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(), Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), - Step::Options => crate::t!("SPLINEDIT [Close/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/Undo/eXit] :").into_owned(), + Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(), Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :").into_owned(), Step::Add => crate::t!("SPLINEDIT Specify a point on the spline :").into_owned(), Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(), @@ -127,12 +148,26 @@ impl CadCommand for SplineditCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match self.step { - Step::Options => vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }, CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")], + Step::Options => { + let mut options = vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }]; + if !self.closed() { options.push(CmdOption::new("Join", "J")); } + options.extend([CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")]); + options + }, Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")], Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")], _ => Vec::new(), } } + fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Join) } + fn selection_entities_exclude_locked(&self) -> bool { matches!(self.step, Step::Join) } + fn inject_selection_entities(&mut self, entities: Vec) { + if matches!(self.step, Step::Join) { self.join_candidates = entities; } + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.join_candidates.retain(|item| handles.contains(&item.handle) && item.handle != self.handle); + CmdResult::NeedPoint + } fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectSpline) } fn inject_before_entity_pick(&self) -> bool { true } fn inject_picked_entity(&mut self, entity: EntityType) { @@ -152,12 +187,13 @@ impl CadCommand for SplineditCommand { } } } - fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } + fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Join | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) } fn on_text_input(&mut self, text: &str) -> Option { let upper = text.trim().to_uppercase(); if upper.is_empty() { return Some(self.on_enter()); } match self.step { Step::Options => match upper.as_str() { + "J" | "JOIN" if !self.closed() => { self.join_candidates.clear(); self.step = Step::Join; }, "R" | "REFINE" => self.step = Step::Refine, "M" | "MOVE" => self.step = Step::Move { index: 0, refine: false }, "X" | "EXIT" => return Some(CmdResult::Cancel), @@ -277,6 +313,7 @@ impl CadCommand for SplineditCommand { fn on_enter(&mut self) -> CmdResult { match self.step { Step::SelectSpline | Step::Options => CmdResult::Cancel, + Step::Join => self.finish_join(), Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint } Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint } Step::Elevate => { @@ -286,6 +323,13 @@ impl CadCommand for SplineditCommand { Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint), } } + fn on_escape(&mut self) -> CmdResult { + if matches!(self.step, Step::Join) { + self.join_candidates.clear(); + self.step = Step::Options; + CmdResult::NeedPoint + } else { CmdResult::Cancel } + } fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { vec![] } } /// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity. From 7181770c73ad080c4b84ec965919423b19e81f6a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:33:35 +0300 Subject: [PATCH 11/13] Elevate spatial spline order through the NURBS kernel --- src/modules/draw/modify/splinedit.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 5e260b414..b4ca5387f 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -105,6 +105,29 @@ impl SplineditCommand { fn refined(&self, point: Option, degree: Option) -> Option { let source = self.spline.as_ref()?; + if let Some(degree) = degree { + let current = usize::try_from(source.degree).ok()?; + if degree <= current || degree > 25 { return None; } + let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; + let curve = cadkernel::space::NurbsCurve3::new_strict(current, + source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), + source.knots.clone(), weights)? + .with_periodicity(source.flags.closed || source.flags.periodic) + .elevated(degree - current)?; + let mut result = source.clone(); + result.degree = curve.degree() as i32; + result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); + result.knots = curve.knots().to_vec(); + result.weights = curve.weights().to_vec(); + result.fit_points.clear(); + result.begin_tangent = Vector3::ZERO; + result.end_tangent = Vector3::ZERO; + result.dwg_flags1 &= !1; + result.dxf_flags &= !(32 | 1024); + result.flags.rational = curve.is_rational(); + result.flags.planar = crate::entities::curve::spline_is_planar(&result); + return Some(result); + } let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?; let cadkernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else { return None; }; if let Some(point) = point { @@ -115,10 +138,6 @@ impl SplineditCommand { if parameter <= start || parameter >= end { return None; } curve.insert_knot(parameter); } - if let Some(degree) = degree { - if degree <= curve.degree() || degree > 25 { return None; } - curve = curve.elevated(degree - curve.degree())?; - } let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source); result.control_points = curve.control_points().iter().map(|point| { let world = planar.plane.point_at(*point); From ed6e962b7850064a49fd7ce6dd9302127dd153e4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:33:49 +0300 Subject: [PATCH 12/13] Compact redundant polynomial knots after order elevation --- src/modules/draw/modify/splinedit.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index b4ca5387f..7f164aec7 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -113,7 +113,8 @@ impl SplineditCommand { source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), source.knots.clone(), weights)? .with_periodicity(source.flags.closed || source.flags.periodic) - .elevated(degree - current)?; + .elevated(degree - current)? + .compact_knots(source.control_tolerance.max(1e-9))?; let mut result = source.clone(); result.degree = curve.degree() as i32; result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect(); From 29cd7fa82b449e4fee35cfebe1fa739a82ddc40b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:35:34 +0300 Subject: [PATCH 13/13] Resolve fit-only spatial splines before order elevation --- src/modules/draw/modify/splinedit.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 7f164aec7..f18ce5920 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -109,10 +109,24 @@ impl SplineditCommand { let current = usize::try_from(source.degree).ok()?; if degree <= current || degree > 25 { return None; } let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() }; - let curve = cadkernel::space::NurbsCurve3::new_strict(current, - source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), - source.knots.clone(), weights)? - .with_periodicity(source.flags.closed || source.flags.periodic) + let curve = if source.control_points.is_empty() && source.fit_points.len() >= 2 { + use cadkernel::space::{NurbsCurve3, Parameterization}; + let points: Vec<_> = source.fit_points.iter().map(|point| [point.x, point.y, point.z]).collect(); + let parameterization = match source.knot_parameterization { + 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, + }; + let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0).then_some([point.x, point.y, point.z]); + if source.flags.closed || source.flags.periodic { + NurbsCurve3::interpolate_periodic(&points, parameterization)? + } else { + NurbsCurve3::interpolate_fit(&points, tangent(&source.begin_tangent), tangent(&source.end_tangent), parameterization)? + } + } else { + cadkernel::space::NurbsCurve3::new_strict(current, + source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), + source.knots.clone(), weights)? + }; + let curve = curve.with_periodicity(source.flags.closed || source.flags.periodic) .elevated(degree - current)? .compact_knots(source.control_tolerance.max(1e-9))?; let mut result = source.clone();