Files
OneCAD-Draft/src/modules/draw/modify/splinedit.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

979 lines
50 KiB
Rust

// SPLINEDIT command — interactive spline editing.
//
// Phase 1: select a spline (entity pick)
// Phase 2: choose a sub-command:
// CLOSE — set the spline as closed (wrap last control point to first)
// OPEN — remove the closure
// REVERSE— reverse the control point order
// EXIT — done (Enter / Escape)
//
// Control-point dragging is already supported via the grip editing system.
use codec::types::Vector3;
use codec::EntityType;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "SPLINEDIT",
label: "Spline Edit",
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/spline.svg")),
event: ModuleEvent::Command("SPLINEDIT".to_string()),
}
}
#[derive(Clone, Copy)]
enum Step {
SelectSpline,
Options,
Refine,
Join,
PolylinePrecision,
FitOptions,
FitTangentStart,
FitTangentEnd { start: DVec3 },
FitMove { index: usize },
FitSelectMove,
FitDelete,
FitAddPick,
FitAddNew { index: usize, first: bool },
Add,
Delete,
Elevate,
Move { index: usize, refine: bool },
Weight { index: usize },
SelectVertex { weight: bool, refine: bool },
}
pub struct SplineditCommand {
step: Step,
handle: codec::Handle,
spline: Option<codec::entities::Spline>,
pending: Option<codec::entities::Spline>,
history: Vec<(codec::Handle, codec::entities::Spline)>,
pick_context: Option<crate::command::PointPickContext>,
join_candidates: Vec<crate::command::SelectionEntity>,
delete_source: bool,
}
fn clear_fit_method(spline: &mut codec::entities::Spline) {
spline.fit_points.clear();
spline.begin_tangent = Vector3::ZERO;
spline.end_tangent = Vector3::ZERO;
spline.dwg_flags1 &= !1;
spline.dxf_flags &= !(32 | 1024);
}
fn weights_are_rational(weights: &[f64]) -> bool {
weights.first().is_some_and(|first| {
weights
.iter()
.any(|weight| (*weight - *first).abs() > 1e-12)
})
}
impl SplineditCommand {
pub fn new() -> Self {
Self { step: Step::SelectSpline, handle: codec::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new(), delete_source: true }
}
pub fn with_delete_source(mut self, delete: bool) -> Self { self.delete_source = delete; self }
fn convert_polyline(&mut self, precision: u8) -> CmdResult {
let result = self.spline.as_ref().and_then(|source| {
let curve = spatial_spline(source)?;
let approximation = curve.to_polyline_precision(precision)?;
let mut points = approximation.points;
if self.closed() {
if points.first() != points.last() { return None; }
points.pop();
}
if points.len() < 2 { return None; }
let entity = if let Some(planar) = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone())) {
let normal = planar.plane.normal()?;
let elevation = kernel::space::Vec3::from(points[0]).dot(kernel::space::Vec3::from(normal));
let normal = Vector3::new(normal[0], normal[1], normal[2]);
let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation);
let mut polyline = codec::LwPolyline::new();
polyline.common = source.common.clone();
polyline.elevation = elevation; polyline.normal = normal; polyline.is_closed = self.closed();
polyline.vertices = points.iter().map(|point| {
let uv = plane.project(*point)?;
Some(codec::entities::LwVertex::new(codec::types::Vector2::new(uv[0], uv[1])))
}).collect::<Option<Vec<_>>>()?;
EntityType::LwPolyline(polyline)
} else {
let mut polyline = codec::entities::Polyline3D::from_points(points.iter().map(|p| Vector3::new(p[0],p[1],p[2])).collect());
polyline.common = source.common.clone(); polyline.flags.closed = self.closed();
EntityType::Polyline3D(polyline)
};
Some(entity)
});
let Some(mut entity) = result else { return CmdResult::ReportError(crate::t!("Spline cannot be converted within the requested precision.").into_owned()); };
if self.delete_source { CmdResult::ReplaceMany(vec![(self.handle,vec![entity])], Vec::new()) }
else { entity.common_mut().handle = codec::Handle::NULL; CmdResult::ReplaceMany(Vec::new(), vec![entity]) }
}
fn picked_vertex(&self, point: DVec3) -> Option<usize> { self.picked_from_points(point, &self.spline.as_ref()?.control_points) }
fn picked_fit_point(&self, point: DVec3) -> Option<usize> { self.picked_from_points(point, &self.spline.as_ref()?.fit_points) }
fn has_fit_data(&self) -> bool { self.spline.as_ref().is_some_and(|s| s.fit_points.len() >= 2) }
fn replace_fit_points(&mut self, points: Vec<Vector3>) -> CmdResult {
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
if points == source.fit_points { return CmdResult::NeedPoint; }
let mut result = source.clone();
result.fit_points = points;
self.rebuild_fit(result)
}
fn rebuild_fit(&mut self, mut result: codec::entities::Spline) -> CmdResult {
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
if source.fit_tolerance != 0.0 || source.weights.windows(2).any(|w| w[0] != w[1]) {
return CmdResult::ReportError(crate::t!("Editing weighted or tolerance-fitted interpolation data is not supported.").into_owned());
}
result.control_points.clear(); result.knots.clear(); result.weights.clear();
let Some(curve) = spatial_spline(&result).and_then(|curve| curve.compact_knots(source.control_tolerance.max(1e-9))) else {
return CmdResult::ReportError(crate::t!("Fit points do not define a valid spline.").into_owned());
};
result.degree = curve.degree() as i32;
result.control_points = curve.control_points().iter().map(|p| Vector3::new(p[0],p[1],p[2])).collect();
result.knots = curve.knots().to_vec(); result.weights = curve.weights().to_vec();
result.dwg_flags1 |= 1; result.dxf_flags |= 32 | 1024;
result.flags.rational = false;
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
self.replace(result)
}
fn purge_fit(&mut self) -> CmdResult {
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
let mut result = source.clone();
if crate::entities::spline::uses_fit_method(&result) {
if result.fit_tolerance != 0.0 { return CmdResult::ReportError(crate::t!("Fit data cannot be purged without a stored control curve.").into_owned()); }
let Some(curve) = spatial_spline(source).and_then(|c| c.compact_knots(source.control_tolerance.max(1e-9))) else {
return CmdResult::ReportError(crate::t!("Fit data does not define a valid control curve.").into_owned());
};
result.degree = curve.degree() as i32;
result.control_points = curve.control_points().iter().map(|p| Vector3::new(p[0],p[1],p[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 = weights_are_rational(&result.weights);
self.step = Step::Options;
self.replace(result)
}
fn finish_fit_tangents(&mut self, start: DVec3, end: DVec3) -> CmdResult {
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
self.step = Step::FitOptions;
let begin = Vector3::new(start.x,start.y,start.z);
let end = Vector3::new(end.x,end.y,end.z);
if source.begin_tangent == begin && source.end_tangent == end { return CmdResult::NeedPoint; }
let mut result = source.clone(); result.begin_tangent = begin; result.end_tangent = end;
self.rebuild_fit(result)
}
fn picked_from_points(&self, point: DVec3, points: &[Vector3]) -> Option<usize> {
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)?;
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 closed(&self) -> bool {
self.spline.as_ref().is_some_and(|spline| spline.flags.closed || spline.flags.periodic)
}
fn replace(&mut self, spline: codec::entities::Spline) -> CmdResult {
self.pending = Some(spline.clone());
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<DVec3>, degree: Option<usize>) -> Option<codec::entities::Spline> {
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 curve = spatial_spline(source)?;
let elevation = degree.checked_sub(curve.degree())?;
let curve = if elevation == 0 { curve } else { curve.elevated(elevation)? }
.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();
result.knots = curve.knots().to_vec();
result.weights = curve.weights().to_vec();
clear_fit_method(&mut result);
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 kernel::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 = kernel::geom2d::closest_point(&kernel::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);
}
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();
clear_fit_method(&mut result);
result.flags.rational = curve.is_rational();
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
Some(result)
}
}
impl CadCommand for SplineditCommand {
fn name(&self) -> &'static str { "SPLINEDIT" }
fn prompt(&self) -> String {
match self.step {
Step::FitOptions if self.closed() => crate::t!("SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] <eXit>:").into_owned(),
Step::FitOptions => crate::t!("SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] <eXit>:").into_owned(),
Step::FitTangentStart => crate::t!("SPLINEDIT Specify start tangent or [System default]:").into_owned(),
Step::FitTangentEnd { .. } => crate::t!("SPLINEDIT Specify end tangent or [System default]:").into_owned(),
Step::FitMove { .. } => crate::t!("SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] <Next>:").into_owned(),
Step::FitSelectMove | Step::FitDelete | Step::FitAddPick => crate::t!("SPLINEDIT Specify existing fit point on spline <exit>:").into_owned(),
Step::FitAddNew { first: true, .. } => crate::t!("SPLINEDIT Specify new point or [After/Before] <exit>:").into_owned(),
Step::FitAddNew { .. } => crate::t!("SPLINEDIT Specify new fit point to add <exit>:").into_owned(),
Step::Options if self.has_fit_data() && self.closed() => crate::t!("SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
Step::Options if self.has_fit_data() => crate::t!("SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(),
Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").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] <eXit>:").into_owned(),
Step::Add => crate::t!("SPLINEDIT Specify a point on the spline <exit>:").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] <Next>:", 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] <Next>:", index + 1),
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
Step::FitOptions => {
let mut options = vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Move", "M"), CmdOption::new("Purge", "P")];
if !self.closed() { options.push(CmdOption::new("Tangents", "T")); }
options.push(CmdOption::new("Exit", "X")); options
},
Step::FitTangentStart | Step::FitTangentEnd { .. } => vec![CmdOption::new("System default", "S")],
Step::FitMove { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")],
Step::FitAddNew { first: true, .. } => vec![CmdOption::new("After", "A"), CmdOption::new("Before", "B")],
Step::Options => {
let mut options = vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }];
if self.has_fit_data() { options.insert(0, CmdOption::new("Fit data", "F")); }
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("Polyline (lines)", "P"), 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<crate::command::SelectionEntity>) {
if matches!(self.step, Step::Join) { self.join_candidates = entities; }
}
fn on_selection_complete(&mut self, handles: Vec<codec::Handle>) -> 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) {
self.spline = match entity { EntityType::Spline(spline) => Some(spline), _ => None };
}
fn on_entity_pick(&mut self, handle: codec::Handle, _pt: DVec3) -> CmdResult {
if handle.is_null() || self.spline.is_none() { return CmdResult::NeedPoint; }
self.handle = handle;
self.step = Step::Options;
CmdResult::NeedPoint
}
fn on_entity_replaced(&mut self, old: codec::Handle, new: &[codec::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::Join | Step::Add | Step::Delete | Step::FitTangentStart | Step::FitTangentEnd { .. } | Step::Move { .. } | Step::SelectVertex { .. } | Step::FitMove { .. } | Step::FitSelectMove | Step::FitDelete | Step::FitAddPick | Step::FitAddNew { .. }) }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let upper = text.trim().to_uppercase();
if upper.is_empty() { return Some(self.on_enter()); }
match self.step {
Step::Options => match upper.as_str() {
"F" | "FIT" if self.has_fit_data() => self.step = Step::FitOptions,
"P" | "POLYLINE" => self.step = Step::PolylinePrecision,
"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),
"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" => {
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);
if self.spline.as_ref() == Some(&spline) { return Some(CmdResult::NeedPoint); }
return Some(self.replace(spline));
}
_ => {}
},
Step::FitOptions => match upper.as_str() {
"A" | "ADD" => self.step = Step::FitAddPick,
"D" | "DELETE" => self.step = Step::FitDelete,
"M" | "MOVE" => self.step = Step::FitMove { index: 0 },
"P" | "PURGE" => return Some(self.purge_fit()),
"T" | "TANGENTS" if !self.closed() => self.step = Step::FitTangentStart,
"X" | "EXIT" => self.step = Step::Options,
_ => {}
},
Step::FitTangentStart if matches!(upper.as_str(), "S" | "SYSTEM" | "SYSTEM DEFAULT") => self.step = Step::FitTangentEnd { start: DVec3::ZERO },
Step::FitTangentEnd { start } if matches!(upper.as_str(), "S" | "SYSTEM" | "SYSTEM DEFAULT") => return Some(self.finish_fit_tangents(start, DVec3::ZERO)),
Step::FitMove { index } => {
let count = self.spline.as_ref()?.fit_points.len();
if count == 0 { return Some(CmdResult::NeedPoint); }
self.step = match upper.as_str() {
"N" | "NEXT" => Step::FitMove { index: (index + 1) % count },
"P" | "PREVIOUS" => Step::FitMove { index: (index + count - 1) % count },
"S" | "SELECT" | "SELECT POINT" => Step::FitSelectMove,
"X" | "EXIT" => Step::FitOptions, _ => self.step,
};
}
Step::FitAddNew { first: true, .. } => match upper.as_str() {
"A" | "AFTER" => self.step = Step::FitAddNew { index: 1, first: false },
"B" | "BEFORE" => self.step = Step::FitAddNew { index: 0, first: false },
_ => {}
},
Step::PolylinePrecision => {
let Ok(precision) = upper.parse::<u8>() else { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); };
if precision > 99 { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); }
return Some(self.convert_polyline(precision));
}
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 },
"X" | "EXIT" => self.step = Step::Options,
_ => {}
},
Step::Elevate => {
let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1;
let Some(order) = upper.parse::<usize>().ok()
.filter(|order| *order >= current_order && *order <= 26) else {
return Some(CmdResult::NeedPoint);
};
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));
}
}
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::<f64>().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 = weights_are_rational(&spline.weights);
clear_fit_method(&mut spline);
return Some(self.replace(spline));
}
}
}
_ => {}
}
Some(CmdResult::NeedPoint)
}
fn wants_point_pick_context(&self) -> bool {
matches!(self.step, Step::Delete | Step::SelectVertex { .. } | Step::FitDelete | Step::FitSelectMove | Step::FitAddPick)
}
fn set_point_pick_context(&mut self, context: Option<crate::command::PointPickContext>) {
self.pick_context = context;
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
if !point.is_finite() { return CmdResult::NeedPoint; }
match self.step {
Step::FitSelectMove | Step::FitDelete | Step::FitAddPick => {
let Some(index) = self.picked_fit_point(point) else { return CmdResult::NeedPoint; };
match self.step {
Step::FitSelectMove => { self.step = Step::FitMove { index }; CmdResult::NeedPoint }
Step::FitAddPick => { self.step = Step::FitAddNew { index: index + 1, first: index == 0 }; CmdResult::NeedPoint }
_ => {
let mut points = self.spline.as_ref().unwrap().fit_points.clone();
if points.len() <= 2 { self.step = Step::FitOptions; return CmdResult::ReportError(crate::t!("Cannot delete beyond this.").into_owned()); }
points.remove(index); self.replace_fit_points(points)
}
}
}
Step::FitTangentStart | Step::FitTangentEnd { .. } => {
let source = self.spline.as_ref().unwrap();
let first = matches!(self.step, Step::FitTangentStart);
let endpoint = if first { source.fit_points.first() } else { source.fit_points.last() }.unwrap();
let endpoint = DVec3::new(endpoint.x,endpoint.y,endpoint.z);
let direction = point - endpoint;
let Some(direction) = direction.try_normalize() else { return CmdResult::ReportError(crate::t!("Tangent point must differ from the fit endpoint.").into_owned()); };
if let Step::FitTangentEnd { start } = self.step { self.finish_fit_tangents(start, direction) }
else { self.step = Step::FitTangentEnd { start: direction }; CmdResult::NeedPoint }
} Step::FitMove { index } => {
let mut points = self.spline.as_ref().unwrap().fit_points.clone();
let Some(vertex) = points.get_mut(index) else { return CmdResult::NeedPoint; };
*vertex = Vector3::new(point.x,point.y,point.z); self.replace_fit_points(points)
}
Step::FitAddNew { index, .. } => {
let mut points = self.spline.as_ref().unwrap().fit_points.clone();
if index > points.len() { return CmdResult::NeedPoint; }
points.insert(index,Vector3::new(point.x,point.y,point.z));
let result = self.replace_fit_points(points);
if matches!(&result, CmdResult::ReplaceManyContinue(_)) { self.step = Step::FitAddNew { index: index + 1, first: false }; }
result
}
Step::SelectVertex { weight, refine } => {
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) = 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() };
let curve = kernel::space::NurbsCurve3::new_strict(source.degree as usize, controls,
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;
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();
clear_fit_method(&mut spline);
spline.flags.rational = curve.is_rational();
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)),
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);
clear_fit_method(&mut spline);
spline.flags.planar = crate::entities::curve::spline_is_planar(&spline);
self.replace(spline)
}
_ => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
Step::SelectSpline | Step::Options => CmdResult::Cancel,
Step::FitOptions => { self.step = Step::Options; CmdResult::NeedPoint }
Step::FitTangentStart => {
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
self.step = Step::FitTangentEnd { start: DVec3::new(source.begin_tangent.x,source.begin_tangent.y,source.begin_tangent.z) }; CmdResult::NeedPoint
}
Step::FitTangentEnd { start } => {
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
self.finish_fit_tangents(start,DVec3::new(source.end_tangent.x,source.end_tangent.y,source.end_tangent.z))
}
Step::FitDelete | Step::FitAddPick | Step::FitSelectMove => { self.step = Step::FitOptions; CmdResult::NeedPoint }
Step::FitAddNew { .. } => { self.step = Step::FitAddPick; CmdResult::NeedPoint }
Step::FitMove { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint),
Step::Join => self.finish_join(),
Step::PolylinePrecision => self.convert_polyline(10),
Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint }
Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint }
Step::Elevate => {
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),
}
}
fn on_escape(&mut self) -> CmdResult {
if matches!(self.step, Step::Join | Step::PolylinePrecision) {
self.join_candidates.clear();
self.step = Step::Options;
CmdResult::NeedPoint
} else { CmdResult::Cancel }
}
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> { vec![] }
}
/// 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 codec::CadDocument, handle: codec::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 codec::entities::Spline, op: &str) {
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: &codec::entities::Spline, closed: bool) -> Option<codec::entities::Spline> {
use kernel::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()?.with_periodicity(false);
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 = !fit_method && 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)
}
#[cfg(test)]
mod tests {
use super::*;
use codec::entities::Spline;
use codec::Handle;
use iced::Rectangle;
fn control_spline(points: &[[f64; 3]], degree: usize) -> Spline {
let mut spline = Spline::new();
spline.degree = degree as i32;
spline.control_points = points
.iter()
.map(|point| Vector3::new(point[0], point[1], point[2]))
.collect();
spline.knots = kernel::space::clamped_uniform_knots(degree, points.len());
spline
}
fn selected_command(spline: Spline) -> SplineditCommand {
let mut command = SplineditCommand::new();
command.inject_picked_entity(EntityType::Spline(spline));
command.on_entity_pick(Handle::new(7), DVec3::ZERO);
command
}
fn replacement(result: CmdResult) -> Spline {
let CmdResult::ReplaceManyContinue(mut replacements) = result else {
panic!("expected a continuing replacement");
};
let Some(EntityType::Spline(spline)) = replacements[0].1.pop() else {
panic!("expected a spline replacement");
};
spline
}
#[test]
fn moving_a_control_vertex_clears_fit_state_and_recomputes_planarity() {
let mut spline = control_spline(
&[
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 1.0, 0.0],
[3.0, 0.0, 0.0],
],
3,
);
spline.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)];
spline.begin_tangent = Vector3::new(1.0, 0.0, 0.0);
spline.end_tangent = Vector3::new(1.0, 0.0, 0.0);
spline.dwg_flags1 = 1;
spline.dxf_flags = 32 | 1024;
spline.flags.planar = true;
let mut command = selected_command(spline);
command.on_text_input("MOVE");
let moved = replacement(command.on_point(DVec3::new(0.0, 0.0, 1.0)));
assert!(moved.fit_points.is_empty());
assert_eq!(moved.begin_tangent, Vector3::ZERO);
assert_eq!(moved.end_tangent, Vector3::ZERO);
assert_eq!(moved.dwg_flags1 & 1, 0);
assert_eq!(moved.dxf_flags & (32 | 1024), 0);
assert!(!moved.flags.planar);
}
#[test]
fn equal_control_weights_are_not_marked_rational() {
let spline = control_spline(
&[
[0.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[2.0, 1.0, 0.0],
[3.0, 0.0, 0.0],
],
3,
);
let mut command = selected_command(spline);
command.on_text_input("REFINE");
command.on_text_input("WEIGHT");
let weighted = replacement(command.on_text_input("1").unwrap());
assert!(!weighted.flags.rational);
assert_eq!(weighted.weights, vec![1.0; 4]);
}
#[test]
fn deleting_the_only_weighted_vertex_clears_rational_and_fit_flags() {
let mut spline = control_spline(
&[
[-0.6, 0.0, 0.0],
[-0.2, 0.4, 0.0],
[0.2, 0.4, 0.0],
[0.6, 0.0, 0.0],
],
2,
);
spline.weights = vec![1.0, 2.0, 1.0, 1.0];
spline.flags.rational = true;
spline.dwg_flags1 = 1;
spline.dxf_flags = 32 | 1024;
let mut command = selected_command(spline);
command.on_text_input("REFINE");
command.on_text_input("DELETE");
command.pick_context = Some(crate::command::PointPickContext {
view: glam::Mat4::IDENTITY,
eye: DVec3::ZERO,
bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)),
aperture_px: 2.0,
});
let deleted = replacement(command.on_point(DVec3::new(-0.2, 0.4, 0.0)));
assert_eq!(deleted.control_points.len(), 3);
assert!(!deleted.flags.rational);
assert_eq!(deleted.dwg_flags1 & 1, 0);
assert_eq!(deleted.dxf_flags & (32 | 1024), 0);
}
#[test]
fn fit_only_degree_elevation_uses_the_interpolated_curve_degree() {
let mut spline = Spline::new();
spline.degree = 1;
spline.fit_points = vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 2.0, 0.0),
Vector3::new(3.0, 0.0, 0.0),
];
let mut command = selected_command(spline);
command.on_text_input("REFINE");
command.on_text_input("ELEVATE");
assert!(matches!(command.on_text_input("invalid"), Some(CmdResult::NeedPoint)));
assert!(matches!(command.step, Step::Elevate));
let elevated = replacement(command.on_text_input("5").unwrap());
assert_eq!(elevated.degree, 4);
assert!(elevated.fit_points.is_empty());
}
#[test]
fn a_closed_weighted_control_curve_can_be_opened() {
let curve = kernel::space::NurbsCurve3::from_weighted_control_polygon(
2,
&[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 2.0, 1.0],
[0.0, 2.0, 0.0],
],
&[1.0, 2.0, 0.75, 1.5],
true,
)
.unwrap();
let mut spline = control_spline(curve.control_points(), curve.degree());
spline.knots = curve.knots().to_vec();
spline.weights = curve.weights().to_vec();
spline.flags.closed = true;
spline.flags.periodic = true;
spline.flags.rational = true;
let opened = change_closure(&spline, false).expect("closed control curve opens");
assert!(!opened.flags.closed);
assert!(!opened.flags.periodic);
assert!(kernel::space::NurbsCurve3::new_strict(
opened.degree as usize,
opened
.control_points
.iter()
.map(|point| [point.x, point.y, point.z])
.collect(),
opened.knots.clone(),
opened.weights.clone(),
)
.is_some());
}
#[test]
fn control_vertex_pick_uses_the_screen_aperture() {
let spline = control_spline(&[[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]], 1);
let mut command = selected_command(spline);
command.pick_context = Some(crate::command::PointPickContext {
view: glam::Mat4::IDENTITY,
eye: DVec3::ZERO,
bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)),
aperture_px: 2.0,
});
assert_eq!(command.picked_vertex(DVec3::new(-0.49, 0.0, 0.0)), Some(0));
assert_eq!(command.picked_vertex(DVec3::new(0.0, 0.5, 0.0)), None);
}
#[test]
fn planar_polyline_conversion_honors_source_deletion_policy() {
let handle = Handle::new(41);
let mut spline = control_spline(
&[
[0.0, 0.0, 2.0],
[1.0, 2.0, 2.0],
[3.0, 0.0, 2.0],
],
2,
);
spline.common.handle = handle;
spline.flags.planar = true;
let mut command = SplineditCommand::new().with_delete_source(false);
command.inject_picked_entity(EntityType::Spline(spline));
command.on_entity_pick(handle, DVec3::ZERO);
let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else {
panic!("expected a polyline conversion");
};
assert!(replacements.is_empty());
let [EntityType::LwPolyline(polyline)] = additions.as_slice() else {
panic!("expected a lightweight polyline");
};
assert!(polyline.common.handle.is_null());
assert_eq!(polyline.elevation, 2.0);
assert!(polyline.vertices.len() > 2);
}
#[test]
fn nonplanar_spline_converts_to_a_spatial_polyline() {
let spline = control_spline(
&[
[0.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[2.0, 1.0, 0.0],
[3.0, 0.0, 2.0],
],
3,
);
let mut command = selected_command(spline);
let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else {
panic!("expected a polyline conversion");
};
assert!(additions.is_empty());
let [(_, entities)] = replacements.as_slice() else {
panic!("expected one source replacement");
};
let [EntityType::Polyline3D(polyline)] = entities.as_slice() else {
panic!("expected a spatial polyline");
};
assert!(polyline.vertices.len() > 2);
}
#[test]
fn closed_conversion_closes_once_and_invalid_precision_retries() {
let curve = kernel::space::NurbsCurve3::from_control_polygon(
2,
&[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 2.0, 0.0],
[0.0, 2.0, 0.0],
],
true,
)
.unwrap();
let mut spline = control_spline(curve.control_points(), curve.degree());
spline.knots = curve.knots().to_vec();
spline.weights = curve.weights().to_vec();
spline.flags.closed = true;
spline.flags.periodic = true;
let mut command = selected_command(spline);
command.on_text_input("POLYLINE");
assert!(matches!(command.on_text_input("100"), Some(CmdResult::ReportError(_))));
assert!(matches!(command.step, Step::PolylinePrecision));
let CmdResult::ReplaceMany(replacements, _) = command.on_enter() else {
panic!("expected the default conversion");
};
let [(_, entities)] = replacements.as_slice() else {
panic!("expected one source replacement");
};
let [EntityType::LwPolyline(polyline)] = entities.as_slice() else {
panic!("expected a lightweight polyline");
};
assert!(polyline.is_closed);
assert!(polyline.vertices.len() >= 4);
assert_ne!(polyline.vertices.first().unwrap().location, polyline.vertices.last().unwrap().location);
}
#[test]
fn two_point_fit_data_accepts_forward_endpoint_tangents() {
let mut spline = Spline::new();
spline.degree = 3;
spline.fit_points = vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(4.0, 0.0, 0.0),
];
let mut command = selected_command(spline);
assert!(command.has_fit_data());
command.on_text_input("FIT");
command.on_text_input("TANGENTS");
assert!(matches!(command.on_point(DVec3::new(1.0, 0.0, 0.0)), CmdResult::NeedPoint));
let edited = replacement(command.on_point(DVec3::new(5.0, 0.0, 0.0)));
assert_eq!(edited.begin_tangent, Vector3::new(1.0, 0.0, 0.0));
assert_eq!(edited.end_tangent, Vector3::new(1.0, 0.0, 0.0));
}
#[test]
fn purge_rebuilds_an_insufficient_stored_control_curve() {
let mut spline = Spline::new();
spline.degree = 3;
spline.flags.rational = true;
spline.control_points = vec![Vector3::ZERO];
spline.fit_points = vec![
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 2.0, 0.0),
Vector3::new(3.0, 2.0, 0.0),
Vector3::new(4.0, 0.0, 0.0),
];
let mut command = selected_command(spline);
command.on_text_input("FIT");
let purged = replacement(command.on_text_input("PURGE").unwrap());
assert!(purged.fit_points.is_empty());
assert!(purged.control_points.len() > purged.degree as usize);
assert!(!purged.flags.rational);
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand
fn spatial_spline(source: &codec::entities::Spline) -> Option<kernel::space::NurbsCurve3> {
let curve = if crate::entities::spline::uses_fit_method(source) {
crate::entities::spline::fit_nurbs3(source)?
} else {
let current = usize::try_from(source.degree).ok()?;
let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() };
kernel::space::NurbsCurve3::new_strict(current,
source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(),
source.knots.clone(), weights)?
};
Some(curve.with_periodicity(source.flags.closed || source.flags.periodic))
}