From da34032ffc75b64d608c880e07abc3908ee4804d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 11 Sep 2026 13:23:00 +0300 Subject: [PATCH] Harden spline editing and source joins --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/commands/styleprops.rs | 5 +- src/modules/draw/modify/join.rs | 93 ++++++++++- src/modules/draw/modify/splinedit.rs | 232 +++++++++++++++++++++++++-- 5 files changed, 316 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c38ab3d34..d894754b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,7 +894,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2c189defcfd7dad9499746470abb7380fdef0e20#2c189defcfd7dad9499746470abb7380fdef0e20" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=979e668a2a81ef28147b694510b05f6e87bb96c5#979e668a2a81ef28147b694510b05f6e87bb96c5" dependencies = [ "acadrust", "cavalier_contours", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2c189defcfd7dad9499746470abb7380fdef0e20#2c189defcfd7dad9499746470abb7380fdef0e20" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=979e668a2a81ef28147b694510b05f6e87bb96c5#979e668a2a81ef28147b694510b05f6e87bb96c5" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index d63889d5a..1046cf65a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "b49cbbfc49ba267712045831a601d2f3d0ed386a", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2c189defcfd7dad9499746470abb7380fdef0e20", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2c189defcfd7dad9499746470abb7380fdef0e20" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "979e668a2a81ef28147b694510b05f6e87bb96c5", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "979e668a2a81ef28147b694510b05f6e87bb96c5" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 12264caa1..9f43c15ad 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -3111,12 +3111,11 @@ mod tests { for command in ["SETBYLAYER", "OVERKILL"] { let mut app = fresh_app(); let _ = app.run_command_line(command); - assert_eq!( + assert!( app.tabs[app.active_tab] .active_cmd .as_ref() - .map(|active| active.name()), - Some("SELECT"), + .is_some_and(|active| active.is_selection_gathering()), "{command} should gather an explicit selection" ); } diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 835826b57..f49d9e495 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -27,6 +27,7 @@ impl JoinCommand { pub fn with_source(mut self, handle: Handle, entity: EntityType) -> Self { self.picked = Some(entity); self.on_entity_pick(handle, DVec3::ZERO); self } + fn has_arc_source(&self) -> bool { matches!(&self.source, Some((_, EntityType::Arc(_)))) } } impl CadCommand for JoinCommand { fn name(&self) -> &'static str { "JOIN" } @@ -42,6 +43,8 @@ impl CadCommand for JoinCommand { fn options(&self) -> Vec { if matches!(&self.source,Some((_,EntityType::Arc(_)))) { vec![crate::command::CmdOption::new("Close","L")] } else {Vec::new()} } + fn wants_text_input(&self) -> bool { self.has_arc_source() } + fn point_step_accepts_keywords(&self) -> bool { self.has_arc_source() } fn on_text_input(&mut self,text:&str) -> Option { if matches!(text.trim().to_ascii_uppercase().as_str(),"L"|"CLOSE") { if let Some((handle,EntityType::Arc(arc)))=&self.source { @@ -122,7 +125,7 @@ pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) spline.control_points=joined.control_points().iter().map(|p|Vector3::new(p[0],p[1],p[2])).collect(); spline.knots=joined.knots().to_vec();spline.weights=joined.weights().to_vec(); spline.fit_points.clear();spline.flags.rational=joined.is_rational(); - spline.dwg_flags1 &= !1;spline.dxf_flags &= !32; + spline.dwg_flags1 &= !1;spline.dxf_flags &= !(32 | 1024); spline.flags.planar=cadkernel::space::are_coplanar(joined.control_points(),&[]); spline.begin_tangent=Vector3::new(0.0,0.0,0.0);spline.end_tangent=Vector3::new(0.0,0.0,0.0); Some(EntityType::Spline(spline)) @@ -511,6 +514,94 @@ mod join_tests { l.thickness ); } + + #[test] + fn source_join_consumes_only_compatible_lines_and_keeps_direction() { + let source_handle = Handle::new(11); + let rejected_handle = Handle::new(12); + let joined_handle = Handle::new(13); + let mut source = line(5.0, 0.0, 1.0, 0.0, 2.0); + source.common_mut().handle = source_handle; + let rejected = line(1.0, 0.0, 1.0, 2.0, 0.0); + let joined = line(8.0, 0.0, 6.0, 0.0, 0.0); + let (result, consumed) = join_to_source( + &source, + &[(rejected_handle, &rejected), (joined_handle, &joined)], + ) + .unwrap(); + assert_eq!(consumed, vec![joined_handle]); + let EntityType::Line(result) = result else { + panic!("expected a line"); + }; + assert_eq!(result.common.handle, source_handle); + assert_eq!(result.start, Vector3::new(8.0, 0.0, 0.0)); + assert_eq!(result.end, Vector3::new(1.0, 0.0, 0.0)); + assert_eq!(result.thickness, 2.0); + } + + #[test] + fn source_join_elevates_splines_and_clears_fit_representation() { + let source_handle = Handle::new(21); + let candidate_handle = Handle::new(22); + let mut source = acadrust::entities::Spline::new(); + source.common.handle = source_handle; + source.degree = 1; + source.control_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ]; + source.knots = vec![0.0, 0.0, 1.0, 1.0]; + source.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)]; + source.dwg_flags1 = 1; + source.dxf_flags = 32 | 1024; + let mut candidate = acadrust::entities::Spline::new(); + candidate.degree = 3; + candidate.control_points = vec![ + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(1.3, 0.2, 0.0), + Vector3::new(1.7, 0.8, 0.0), + Vector3::new(2.0, 1.0, 0.0), + ]; + candidate.knots = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]; + let candidate_entity = EntityType::Spline(candidate); + let (result, consumed) = join_to_source( + &EntityType::Spline(source), + &[(candidate_handle, &candidate_entity)], + ) + .unwrap(); + assert_eq!(consumed, vec![candidate_handle]); + let EntityType::Spline(result) = result else { + panic!("expected a spline"); + }; + assert_eq!(result.common.handle, source_handle); + assert_eq!(result.degree, 3); + assert!(result.fit_points.is_empty()); + assert_eq!(result.dwg_flags1 & 1, 0); + assert_eq!(result.dxf_flags & (32 | 1024), 0); + assert!(result.flags.planar); + } + + #[test] + fn arc_source_accepts_the_typed_close_option() { + let handle = Handle::new(31); + let mut arc = acadrust::entities::Arc::new(); + arc.common.handle = handle; + arc.center = Vector3::new(2.0, 3.0, 4.0); + arc.radius = 5.0; + arc.thickness = 0.75; + let mut command = JoinCommand::new().with_source(handle, EntityType::Arc(arc)); + assert!(command.input_kind().wants_text()); + assert!(command.point_step_accepts_keywords()); + let Some(CmdResult::ReplaceMany(mut replacements, _)) = command.on_text_input("Close") else { + panic!("expected the arc to close"); + }; + let Some(EntityType::Circle(circle)) = replacements[0].1.pop() else { + panic!("expected a circle"); + }; + assert_eq!(circle.center, Vector3::new(2.0, 3.0, 4.0)); + assert_eq!(circle.radius, 5.0); + assert_eq!(circle.thickness, 0.75); + } } /// True when every vertex lies on one straight line (within tolerance). diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index f18ce5920..cd7bf22d0 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -52,6 +52,22 @@ pub struct SplineditCommand { join_candidates: Vec, } +fn clear_fit_method(spline: &mut acadrust::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: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new() } @@ -126,19 +142,16 @@ impl SplineditCommand { 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)? + let curve = curve.with_periodicity(source.flags.closed || source.flags.periodic); + 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(); - result.fit_points.clear(); - result.begin_tangent = Vector3::ZERO; - result.end_tangent = Vector3::ZERO; - result.dwg_flags1 &= !1; - result.dxf_flags &= !(32 | 1024); + clear_fit_method(&mut result); result.flags.rational = curve.is_rational(); result.flags.planar = crate::entities::curve::spline_is_planar(&result); return Some(result); @@ -158,6 +171,9 @@ impl SplineditCommand { 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) } } @@ -262,7 +278,10 @@ impl CadCommand for SplineditCommand { }, Step::Elevate => { 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)?; + let Some(order) = upper.parse::().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); @@ -290,8 +309,8 @@ impl CadCommand for SplineditCommand { let mut spline = self.spline.clone()?; spline.weights.resize(count, 1.0); spline.weights[index] = weight; - spline.flags.rational = true; - spline.fit_points.clear(); + spline.flags.rational = weights_are_rational(&spline.weights); + clear_fit_method(&mut spline); return Some(self.replace(spline)); } } @@ -329,7 +348,8 @@ impl CadCommand for SplineditCommand { 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(); + clear_fit_method(&mut spline); + spline.flags.rational = curve.is_rational(); spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); self.replace(spline) } @@ -338,7 +358,8 @@ impl CadCommand for SplineditCommand { 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(); + clear_fit_method(&mut spline); + spline.flags.planar = crate::entities::curve::spline_is_planar(&spline); self.replace(spline) } _ => CmdResult::NeedPoint, @@ -418,7 +439,7 @@ fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option Option Option 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 = cadkernel::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 = cadkernel::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!(cadkernel::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); + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand