// JOIN command — merge collinear Lines or co-circular Arcs into one entity. // // Supports: // Line + Line → merged Line (if collinear and end-to-end) // Arc + Arc → merged Arc (if same center/radius and contiguous) // Arc + Arc → Circle (if the merged arc spans 360°) // // Workflow: select objects then press Enter to join. use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::Vec3; use crate::command::{CadCommand, CmdResult}; // ── Command ──────────────────────────────────────────────────────────────── pub struct JoinCommand { handles: Vec, gathering: bool, } impl JoinCommand { pub fn new() -> Self { Self { handles: vec![], gathering: true, } } } impl CadCommand for JoinCommand { fn name(&self) -> &'static str { "JOIN" } fn prompt(&self) -> String { format!( "JOIN Select objects to join ({} selected, Enter to apply):", self.handles.len() ) } fn is_selection_gathering(&self) -> bool { self.gathering } fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { self.handles = handles; CmdResult::NeedPoint } fn on_point(&mut self, _pt: Vec3) -> CmdResult { CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { if self.handles.len() < 2 { return CmdResult::Cancel; } self.gathering = false; CmdResult::JoinEntities(self.handles.clone()) } } // ── Geometry ─────────────────────────────────────────────────────────────── /// Try to join all `entities` in the slice into a minimal set. /// Returns `(kept, removed)` handle lists and the merged entity vec. pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, Vec)> { if entities.len() < 2 { return None; } // Split into lines and arcs let lines: Vec<_> = entities .iter() .filter(|(_, e)| matches!(e, EntityType::Line(_))) .collect(); let arcs: Vec<_> = entities .iter() .filter(|(_, e)| matches!(e, EntityType::Arc(_))) .collect(); if !lines.is_empty() && arcs.is_empty() { return try_join_lines(&lines); } if !arcs.is_empty() && lines.is_empty() { return try_join_arcs(&arcs); } None } fn try_join_lines(lines: &[&(Handle, &EntityType)]) -> Option<(Vec, Vec)> { // Collect endpoints in the world XY plane let segs: Vec<_> = lines .iter() .map(|(h, e)| { if let EntityType::Line(l) = e { let s = Vec3::new(l.start.x as f32, l.start.y as f32, 0.0); let e2 = Vec3::new(l.end.x as f32, l.end.y as f32, 0.0); (*h, l, s, e2) } else { unreachable!() } }) .collect(); // Check collinearity: all lines must be parallel and on the same infinite line. let (_, first_line, s0, e0) = segs[0]; let dir0 = (e0 - s0).normalize_or_zero(); if dir0.length_squared() < 1e-12 { return None; } for (_, _, si, ei) in &segs[1..] { // Check parallel let diri = (*ei - *si).normalize_or_zero(); let cross = (dir0.x * diri.y - dir0.y * diri.x).abs(); if cross > 1e-4 { return None; } // Check co-linear (point on same line) let off = *si - s0; let perp = (off - dir0 * off.dot(dir0)).length(); if perp > 1e-3 { return None; } } // Project all endpoints onto the direction axis let params: Vec = segs .iter() .flat_map(|(_, _, si, ei)| [si.dot(dir0), ei.dot(dir0)]) .collect(); let t_min = params.iter().cloned().fold(f32::INFINITY, f32::min); let t_max = params.iter().cloned().fold(f32::NEG_INFINITY, f32::max); // Check connectivity: no gaps larger than tolerance let mut sorted: Vec<[f32; 2]> = segs .iter() .map(|(_, _, si, ei)| { let ta = si.dot(dir0); let tb = ei.dot(dir0); [ta.min(tb), ta.max(tb)] }) .collect(); sorted.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap()); for w in sorted.windows(2) { if w[1][0] > w[0][1] + 1e-3 { return None; } // gap } let new_start = s0 + dir0 * t_min; let new_end = s0 + dir0 * t_max; let mut merged = first_line.clone(); merged.common.handle = Handle::NULL; merged.start = vec3_xz_to_v3(new_start, first_line.start.z); merged.end = vec3_xz_to_v3(new_end, first_line.start.z); let handles: Vec = lines.iter().map(|(h, _)| *h).collect(); Some((handles, vec![EntityType::Line(merged)])) } fn try_join_arcs(arcs: &[&(Handle, &EntityType)]) -> Option<(Vec, Vec)> { let segs: Vec<_> = arcs .iter() .map(|(h, e)| { if let EntityType::Arc(a) = e { (*h, a) } else { unreachable!() } }) .collect(); // All arcs must share the same center and radius let (_, first) = segs[0]; let cx = first.center.x; let cy = first.center.y; let r = first.radius; for (_, a) in &segs[1..] { if (a.center.x - cx).abs() > 1e-3 || (a.center.y - cy).abs() > 1e-3 { return None; } if (a.radius - r).abs() > 1e-3 { return None; } } // Collect (start_angle, end_angle) in radians. let mut intervals: Vec<[f32; 2]> = segs .iter() .map(|(_, a)| [a.start_angle as f32, a.end_angle as f32]) .collect(); // Sort by start angle intervals.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap()); // Try to merge into one contiguous arc let merged_start = intervals[0][0]; let mut merged_end = intervals[0][1]; for &[s, e] in &intervals[1..] { let span = (e - merged_end).rem_euclid(std::f32::consts::TAU); let gap = (s - merged_end).rem_euclid(std::f32::consts::TAU); let _ = span; if gap > 1e-3 { return None; } // discontinuous merged_end = e; } let span = merged_end - merged_start; let handles: Vec = arcs.iter().map(|(h, _)| *h).collect(); if (span - std::f32::consts::TAU).abs() < 0.01 { // Full circle let mut circle = acadrust::entities::Circle::new(); circle.common = first.common.clone(); circle.common.handle = Handle::NULL; circle.center = first.center.clone(); circle.radius = r; circle.normal = first.normal.clone(); return Some((handles, vec![EntityType::Circle(circle)])); } let mut merged_arc = first.clone(); merged_arc.common.handle = Handle::NULL; merged_arc.start_angle = merged_start as f64; merged_arc.end_angle = merged_end as f64; Some((handles, vec![EntityType::Arc(merged_arc)])) } fn vec3_xz_to_v3(v: Vec3, z: f64) -> Vector3 { Vector3::new(v.x as f64, v.y as f64, z) } // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["J", "JOIN"] }); // JoinCommand