diff --git a/Cargo.lock b/Cargo.lock index a13687e90..c2bcc821e 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=b95889d5e2d5271fdf4d43fb1d883f5b091dd738#b95889d5e2d5271fdf4d43fb1d883f5b091dd738" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b414090f1b54191242d2f880cfb1fa08050698bd#b414090f1b54191242d2f880cfb1fa08050698bd" 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=b95889d5e2d5271fdf4d43fb1d883f5b091dd738#b95889d5e2d5271fdf4d43fb1d883f5b091dd738" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b414090f1b54191242d2f880cfb1fa08050698bd#b414090f1b54191242d2f880cfb1fa08050698bd" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 8c262aed1..ff3e437a1 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 = "b95889d5e2d5271fdf4d43fb1d883f5b091dd738", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b95889d5e2d5271fdf4d43fb1d883f5b091dd738" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b414090f1b54191242d2f880cfb1fa08050698bd", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b414090f1b54191242d2f880cfb1fa08050698bd" } 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 c44add7b9..12264caa1 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -739,68 +739,127 @@ impl OpenCADStudio { } } - // ── OVERKILL — delete duplicate (identical) objects ────────── - // Removes objects that are identical in geometry AND properties to - // another object (compared with the handle ignored). Operates on - // the current selection, gathering objects first when nothing is - // selected. Conservative: only exact duplicates are removed. - "OVERKILL" => { - use acadrust::Handle; - if self.tabs[i].scene.selected.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let selection = SelectObjectsCommand::new("OVERKILL"); - self.command_line.push_info(&selection.prompt()); - self.tabs[i].active_cmd = Some(Box::new(selection)); - return Some(self.finish_dispatch(cmd)); - } - let selected: std::collections::HashSet = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h.value()) - .collect(); - // Capture (handle, type-name, handle-normalized clone) for each - // candidate while the document is borrowed immutably. - let candidates: Vec<(Handle, String, acadrust::EntityType)> = self.tabs[i] - .scene - .document - .entities() - .filter(|e| { - selected.contains(&e.common().handle.value()) - && !self.tabs[i].scene.is_layer_locked(e.common().handle) - }) - .map(|e| { - let key = crate::entities::names::dxf_name(e).to_string(); - let mut norm = e.clone(); - norm.common_mut().handle = Handle::NULL; - (e.common().handle, key, norm) - }) - .collect(); - // Bucket by (type, layer) so only like objects are compared. - let mut kept: Vec<(String, acadrust::EntityType)> = Vec::new(); - let mut dups: Vec = Vec::new(); - for (h, key, norm) in &candidates { - let bucket = format!("{key}\u{0}{}", norm.common().layer); - if kept.iter().any(|(b, e)| b == &bucket && e == norm) { - dups.push(*h); - } else { - kept.push((bucket, norm.clone())); + // OVERKILL gathers objects, exposes cleanup settings, then applies one undo group. + "OVERKILL" | "-OVERKILL" => { + let handles=self.tabs[i].scene.selected_entities().iter().map(|(h,_)|*h).collect(); + let command=crate::modules::draw::modify::overkill::OverkillCommand::new(handles); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd=Some(Box::new(command)); + } + value if value.starts_with("OVERKILL_APPLY ") => { + use crate::modules::draw::modify::overkill::{normalized,optimize}; + let values:Vec<_>=value.split_whitespace().skip(1).collect(); + if values.len()!=6 { return Some(self.finish_dispatch(cmd)); } + let tolerance=values[0].parse::().unwrap_or(1e-6); + let ignore=values[1].parse::().unwrap_or(0); + let optimize_plines=values[2]=="1"; + let overlap=values[3]=="1"; + let end_to_end=values[4]=="1"; + let preserve_associative=values[5]=="1"; + let mut candidates:Vec<_>=self.tabs[i].scene.selected_entities().into_iter() + .filter(|(h,e)|!self.tabs[i].scene.is_layer_locked(*h) + && (!preserve_associative||e.common().reactors.is_empty())) + .map(|(h,e)|(h,e.clone())).collect(); + candidates.sort_by_key(|(h,_)|h.value()); + let mut changed=std::collections::HashSet::new(); + if optimize_plines { for (h,e) in &mut candidates { + let before=e.clone(); optimize(e,tolerance); if *e!=before {changed.insert(*h);} + } } + let mut removed=std::collections::HashSet::new(); + // Iterate to a fixed point: a bridge can join two previously disjoint intervals. + loop { + let mut progress=false; + for a in 0..candidates.len() { + if removed.contains(&candidates[a].0) {continue;} + for b in a+1..candidates.len() { + if removed.contains(&candidates[b].0) {continue;} + let left=normalized(&candidates[a].1,ignore); + let right=normalized(&candidates[b].1,ignore); + if left==right { + removed.insert(candidates[b].0);progress=true;continue; + } + if overlap { + let contained = |circle: &acadrust::entities::Circle, arc: &acadrust::entities::Arc| { + circle.common == arc.common && circle.thickness == arc.thickness + && cadkernel::space::arc_union::circle_contains_arc( + [circle.center.x,circle.center.y,circle.center.z], + [circle.normal.x,circle.normal.y,circle.normal.z],circle.radius, + cadkernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z], + normal:[arc.normal.x,arc.normal.y,arc.normal.z],radius:arc.radius,start:arc.start_angle,end:arc.end_angle }) + }; + match (&left,&right) { + (acadrust::EntityType::Circle(circle),acadrust::EntityType::Arc(arc)) if contained(circle,arc) => { + removed.insert(candidates[b].0);progress=true;continue; + } + (acadrust::EntityType::Arc(arc),acadrust::EntityType::Circle(circle)) if contained(circle,arc) => { + removed.insert(candidates[a].0);progress=true;break; + } + _ => {}, + } + } + if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) { + use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union}; + if l.common!=r.common || l.thickness!=r.thickness {continue;} + let arc=|v:&acadrust::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle}; + let Some(union)=circular_arc_union(arc(l),arc(r),tolerance) else {continue;}; + let allowed=match union.kind {ArcUnionKind::Duplicate=>true,ArcUnionKind::Overlap=>overlap,ArcUnionKind::EndToEnd=>end_to_end}; + if !allowed {continue;} + if let acadrust::EntityType::Arc(source)=&candidates[a].1 { + let replacement=if union.full_circle { + let mut circle=acadrust::entities::Circle::new(); + circle.common=source.common.clone();circle.center=source.center.clone();circle.normal=source.normal.clone();circle.radius=source.radius;circle.thickness=source.thickness; + acadrust::EntityType::Circle(circle) + } else { + let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;acadrust::EntityType::Arc(arc) + }; + candidates[a].1=replacement; + } + changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + continue; + } + let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right) + else {continue;}; + if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;} + let point=|p:acadrust::types::Vector3|[p.x,p.y,p.z]; + let Some(union)=cadkernel::space::line_union( + [point(l.start),point(l.end)],[point(r.start),point(r.end)],tolerance) + else {continue;}; + let allowed=match union.kind { + cadkernel::space::LineUnionKind::Duplicate=>true, + cadkernel::space::LineUnionKind::Overlap=>overlap, + cadkernel::space::LineUnionKind::EndToEnd=>end_to_end, + }; + if !allowed {continue;} + if let acadrust::EntityType::Line(line)=&mut candidates[a].1 { + line.start=acadrust::types::Vector3::new(union.start[0],union.start[1],union.start[2]); + line.end=acadrust::types::Vector3::new(union.end[0],union.end[1],union.end[2]); + } + changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + } } + if !progress {break;} } - if dups.is_empty() { - self.command_line - .push_output(crate::t!("OVERKILL: no duplicate objects found.").as_ref()); + if !changed.is_empty()||!removed.is_empty() { + self.push_undo_snapshot(i,"OVERKILL"); + let updates:Vec<_>=candidates.into_iter().filter(|(h,_)|changed.contains(h)&&!removed.contains(h)).collect(); + let updated_count = updates.len(); + let mut changes = Vec::new(); + for (handle, entity) in updates { + if let Some(target) = self.tabs[i].scene.document.get_entity_mut(handle) { + *target = entity; + changes.push((handle, crate::scene::ChangeKind::Modified)); + } + } + self.tabs[i].scene.bump_entities(&changes); + let mut handles:Vec<_>=removed.iter().copied().collect(); + handles.sort_by_key(|handle| handle.value()); + self.tabs[i].scene.erase_entities(&handles); + self.tabs[i].dirty=true;self.refresh_properties(); + self.command_line.push_output(&format!("OVERKILL: removed {} objects; updated {} objects.",removed.len(),updated_count)); } else { - let n = dups.len(); - self.push_undo_snapshot(i, "OVERKILL"); - self.tabs[i].scene.erase_entities(&dups); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(crate::tf!("OVERKILL: deleted {n} duplicate object(s).").as_ref()); + self.command_line.push_output("OVERKILL: no objects required changes."); } } - // ── PICKADD / PICKDRAG — selection UX (#226, app settings) ─── // Bare form reports; ` 0|1` sets and persists. Defaults keep // today's behaviour (PICKADD 1, PICKDRAG 0). diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index 651e4df12..93c2e6aac 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -15,6 +15,7 @@ pub mod mirror; pub mod mledit; pub mod ncopy; pub mod offset; +pub mod overkill; pub mod pedit; pub mod block_edit; pub mod refedit; diff --git a/src/modules/draw/modify/overkill.rs b/src/modules/draw/modify/overkill.rs new file mode 100644 index 000000000..2c7df109c --- /dev/null +++ b/src/modules/draw/modify/overkill.rs @@ -0,0 +1,178 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; +use crate::command::{CadCommand, CmdOption, CmdResult}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub tolerance: f64, + pub ignore: u16, + pub optimize: bool, + pub overlap: bool, + pub end_to_end: bool, + pub associativity: bool, +} +impl Default for Settings { + fn default() -> Self { Self { tolerance: 1e-6, ignore: 0, optimize: true, + overlap: true, end_to_end: true, associativity: true } } +} +fn remembered() -> &'static std::sync::Mutex { + static SETTINGS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SETTINGS.get_or_init(|| std::sync::Mutex::new(Settings::default())) +} +enum Step { Selection, Options, Ignore, Tolerance, Optimize, Overlap, EndToEnd, Associativity } +pub struct OverkillCommand { selected: Vec, step: Step, settings: Settings } +impl OverkillCommand { + pub fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { Step::Selection } else { Step::Options }; + Self { selected, step, settings: *remembered().lock().unwrap_or_else(|e|e.into_inner()) } + } + fn finish(&self) -> CmdResult { + let s = self.settings; + *remembered().lock().unwrap_or_else(|e|e.into_inner())=s; + CmdResult::Relaunch(format!("OVERKILL_APPLY {} {} {} {} {} {}", s.tolerance, + s.ignore, u8::from(s.optimize), u8::from(s.overlap), u8::from(s.end_to_end), + u8::from(s.associativity)), self.selected.clone()) + } +} +impl CadCommand for OverkillCommand { + fn name(&self) -> &'static str { "OVERKILL" } + fn prompt(&self) -> String { + match self.step { + Step::Selection => "Select objects:".into(), + Step::Options => format!("Tolerance={} Ignore={} Optimize={} Overlap={} End-to-end={}\nEnter an option [Done/Ignore/tOlerance/optimize Plines/combine parTial overlap/combine Endtoend/Associativity] :", self.settings.tolerance, self.settings.ignore, self.settings.optimize, self.settings.overlap, self.settings.end_to_end), + Step::Ignore => "Properties to ignore [None/All/Color/LAyer/Ltype/ltScale/LWeight/Thickness/TRansparency/plotSTyle/Material]:".into(), + Step::Tolerance => format!("Specify tolerance <{}>:", self.settings.tolerance), + Step::Optimize => format!("Optimize segments within polylines [Yes/No] <{}>:",if self.settings.optimize{"Yes"}else{"No"}), + Step::Overlap => format!("Combine partially overlapping objects [Yes/No] <{}>:",if self.settings.overlap{"Yes"}else{"No"}), + Step::EndToEnd => format!("Combine end-to-end objects [Yes/No] <{}>:",if self.settings.end_to_end{"Yes"}else{"No"}), + Step::Associativity => format!("Maintain associative objects [Yes/No] <{}>:",if self.settings.associativity{"Yes"}else{"No"}), + } + } + fn options(&self) -> Vec { + match self.step { + Step::Options => [("Done","D"),("Ignore","I"),("Tolerance","O"),("Optimize polylines","P"),("Combine overlap","T"),("Combine end-to-end","E"),("Associativity","A")].into_iter().map(|(l,k)|CmdOption::new(l,k)).collect(), + Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => vec![CmdOption::new("Yes","Y"),CmdOption::new("No","N")], + _ => Vec::new(), + } + } + fn is_selection_gathering(&self) -> bool { matches!(self.step,Step::Selection) } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { self.selected=handles; CmdResult::NeedPoint } + fn wants_text_input(&self) -> bool { !self.is_selection_gathering() } + fn on_point(&mut self, _:DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Selection if self.selected.is_empty() => CmdResult::Cancel, + Step::Options => self.finish(), + _ => { self.step=Step::Options; CmdResult::NeedPoint } + } + } + fn on_text_input(&mut self, text:&str) -> Option { + let k=text.trim().to_ascii_uppercase(); + match self.step { + Step::Options => match k.as_str() { + "D"|"DONE" => return Some(self.finish()), + "I"|"IGNORE" => self.step=Step::Ignore, + "O"|"TOLERANCE" => self.step=Step::Tolerance, + "P"|"PLINES" => self.step=Step::Optimize, + "T"|"PARTIAL" => self.step=Step::Overlap, + "E"|"ENDTOEND" => self.step=Step::EndToEnd, + "A"|"ASSOCIATIVITY" => self.step=Step::Associativity, + _=>{}, + }, + Step::Ignore => { + let flag=match k.as_str() { "N"|"NONE"=>{self.settings.ignore=0;0}, "A"|"ALL"=>511, + "C"|"COLOR"=>1,"LA"|"LAYER"=>2,"L"|"LTYPE"=>4,"S"|"LTSCALE"=>8, + "LW"|"LWEIGHT"=>16,"T"|"THICKNESS"=>32,"TR"|"TRANSPARENCY"=>64, + "ST"|"PLOTSTYLE"=>128,"M"|"MATERIAL"=>256,_=>return Some(CmdResult::NeedPoint)}; + self.settings.ignore|=flag; self.step=Step::Options; + } + Step::Tolerance => if let Ok(v)=k.parse::() { if v.is_finite()&&v>=0.0 { + self.settings.tolerance=v; self.step=Step::Options; + } }, + Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => { + let yes=match k.as_str(){"Y"|"YES"=>true,"N"|"NO"=>false,_=>return Some(CmdResult::NeedPoint)}; + match self.step {Step::Optimize=>self.settings.optimize=yes,Step::Overlap=>self.settings.overlap=yes, + Step::EndToEnd=>self.settings.end_to_end=yes,Step::Associativity=>self.settings.associativity=yes,_=>{}} + self.step=Step::Options; + } + _=>{}, + } + Some(CmdResult::NeedPoint) + } +} + +pub fn normalized(entity:&EntityType, ignore:u16) -> EntityType { + let mut e=entity.clone(); + let common=e.common_mut(); + let default=acadrust::entities::EntityCommon::new(); + common.handle=Handle::NULL; + if ignore&1!=0 {common.color=default.color;common.color_name=None;common.color_book_handle=None;} + if ignore&2!=0 {common.layer.clear();} + if ignore&4!=0 {common.linetype.clear();common.linetype_handle=None;} + if ignore&8!=0 {common.linetype_scale=1.0;} + if ignore&16!=0 {common.line_weight=default.line_weight;} + if ignore&64!=0 {common.transparency=default.transparency;} + if ignore&128!=0 {common.plotstyle_flags=0;common.plotstyle_handle=None;} + if ignore&256!=0 {common.material_flags=0;common.material_handle=None;} + if ignore&32!=0 {match &mut e {EntityType::Line(v)=>v.thickness=0.0, + EntityType::Circle(v)=>v.thickness=0.0,EntityType::Arc(v)=>v.thickness=0.0, + EntityType::LwPolyline(v)=>v.thickness=0.0,_=>{}}} + e +} + +pub fn optimize(entity:&mut EntityType, tolerance:f64) { + if let EntityType::LwPolyline(p)=entity { + // Width changes and bulged spans carry geometry that must be retained. + if p.vertices.iter().any(|v|v.bulge!=0.0||v.start_width!=0.0||v.end_width!=0.0) {return;} + let points:Vec<_>=p.vertices.iter().map(|v|[v.location.x,v.location.y,p.elevation]).collect(); + let indices=cadkernel::space::simplify_linear_chain(&points,tolerance); + if indices.len()>=if p.is_closed{3}else{2} { + p.vertices=indices.into_iter().map(|i|p.vertices[i].clone()).collect(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Line, LwPolyline}; + use acadrust::types::{Color, Vector2}; + + #[test] + fn normalization_ignores_only_the_requested_properties() { + let mut first = Line::from_coords(0.0, 0.0, 0.0, 2.0, 0.0, 0.0); + first.common.handle = Handle::new(1); + first.common.layer = "FIRST".into(); + first.common.color = Color::from_index(1); + let mut second = first.clone(); + second.common.handle = Handle::new(2); + second.common.layer = "SECOND".into(); + second.common.color = Color::from_index(2); + + assert_ne!(normalized(&EntityType::Line(first.clone()), 0), normalized(&EntityType::Line(second.clone()), 0)); + assert_eq!(normalized(&EntityType::Line(first), 1 | 2), normalized(&EntityType::Line(second), 1 | 2)); + } + + #[test] + fn polyline_optimization_removes_only_plain_collinear_vertices() { + let mut plain = EntityType::LwPolyline(LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(1.0, 0.0), + Vector2::new(2.0, 0.0), + ])); + optimize(&mut plain, 1e-9); + let EntityType::LwPolyline(plain) = plain else { unreachable!() }; + assert_eq!(plain.vertices.len(), 2); + + let mut styled = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(1.0, 0.0), + Vector2::new(2.0, 0.0), + ]); + styled.vertices[1].start_width = 1.0; + let mut styled = EntityType::LwPolyline(styled); + optimize(&mut styled, 1e-9); + let EntityType::LwPolyline(styled) = styled else { unreachable!() }; + assert_eq!(styled.vertices.len(), 3); + } +}