diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 6664f3889..c2d0ba95a 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -35,6 +35,14 @@ enum Step { Refine, Join, PolylinePrecision, + FitOptions, + FitTangentStart, + FitTangentEnd { start: DVec3 }, + FitMove { index: usize }, + FitSelectMove, + FitDelete, + FitAddPick, + FitAddNew { index: usize, first: bool }, Add, Delete, Elevate, @@ -112,7 +120,65 @@ impl SplineditCommand { else { entity.common_mut().handle = acadrust::Handle::NULL; CmdResult::ReplaceMany(Vec::new(), vec![entity]) } } - fn picked_vertex(&self, point: DVec3) -> Option { + fn picked_vertex(&self, point: DVec3) -> Option { self.picked_from_points(point, &self.spline.as_ref()?.control_points) } + fn picked_fit_point(&self, point: DVec3) -> Option { 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) -> 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: acadrust::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 { let context = self.pick_context?; let project = |point: DVec3| { let clip = context.view * (point - context.eye).as_vec3().extend(1.0); @@ -124,7 +190,7 @@ impl SplineditCommand { && 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)| { + 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)) @@ -203,6 +269,16 @@ 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] :").into_owned(), + Step::FitOptions => crate::t!("SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/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] :").into_owned(), + Step::FitSelectMove | Step::FitDelete | Step::FitAddPick => crate::t!("SPLINEDIT Specify existing fit point on spline :").into_owned(), + Step::FitAddNew { first: true, .. } => crate::t!("SPLINEDIT Specify new point or [After/Before] :").into_owned(), + Step::FitAddNew { .. } => crate::t!("SPLINEDIT Specify new fit point to add :").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] :").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] :").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] :").into_owned(), Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :").into_owned(), @@ -220,8 +296,17 @@ impl CadCommand for SplineditCommand { fn options(&self) -> Vec { 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 @@ -259,12 +344,13 @@ impl CadCommand for SplineditCommand { } } } - fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Join | 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::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 { 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, @@ -290,6 +376,32 @@ impl CadCommand for SplineditCommand { } _ => {} }, + 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::() 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())); } @@ -348,7 +460,7 @@ impl CadCommand for SplineditCommand { Some(CmdResult::NeedPoint) } fn wants_point_pick_context(&self) -> bool { - matches!(self.step, Step::Delete | Step::SelectVertex { .. }) + matches!(self.step, Step::Delete | Step::SelectVertex { .. } | Step::FitDelete | Step::FitSelectMove | Step::FitAddPick) } fn set_point_pick_context(&mut self, context: Option) { self.pick_context = context; @@ -356,6 +468,40 @@ impl CadCommand for SplineditCommand { 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 } }; @@ -396,6 +542,18 @@ impl CadCommand for SplineditCommand { 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 } @@ -763,27 +921,55 @@ mod tests { 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: &acadrust::entities::Spline) -> Option { - 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() }; - 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)? - } + 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() }; cadkernel::space::NurbsCurve3::new_strict(current, source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), source.knots.clone(), weights)?