From 641cc2d70be027ab5131665d4f5e1c19e75d9878 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 2 Apr 2026 23:41:53 +0300 Subject: [PATCH] =?UTF-8?q?Feat:=20JOIN=20command=20=E2=80=94=20merge=20co?= =?UTF-8?q?llinear=20Lines=20or=20co-circular=20Arcs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection-gathering mode: select objects, press Enter to apply. - Lines: checks collinearity + no-gap, merges into single line span - Arcs: checks same center/radius + contiguous angles, merges to Arc or Circle - Uses CmdResult::JoinEntities; geometry computed in cmd_result from document Co-Authored-By: Claude Sonnet 4.6 --- src/app/cmd_result.rs | 36 ++++++ src/app/commands.rs | 11 +- src/command/mod.rs | 2 + src/modules/home/modify/join.rs | 198 ++++++++++++++++++++++++++++++++ src/modules/home/modify/mod.rs | 1 + 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/modules/home/modify/join.rs diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 331fec80..b477b2f9 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -371,6 +371,42 @@ impl H7CAD { self.restore_pre_cmd_tangent(); self.command_line.push_output(&msg); } + CmdResult::JoinEntities(handles) => { + use crate::modules::home::modify::join::join_entities; + let pairs: Vec<_> = handles.iter() + .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).map(|e| (h, e))) + .collect(); + match join_entities(&pairs) { + Some((to_remove, merged)) => { + let label = self.history_label_from_active_cmd(i, "JOIN"); + self.push_undo_snapshot(i, label); + self.tabs[i].scene.erase_entities(&to_remove); + let count_in = to_remove.len(); + let count_out = merged.len(); + for e in merged { + self.tabs[i].scene.add_entity(e); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_output( + &format!("JOIN: {count_in} object(s) joined into {count_out}.") + ); + self.refresh_properties(); + } + None => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line.push_error( + "JOIN: objects are not collinear/co-circular or have gaps." + ); + } + } + } CmdResult::BreakEntity { handle, p1, p2 } => { use crate::modules::home::modify::break_cmd::break_entity; let replacement = self.tabs[i].scene.document diff --git a/src/app/commands.rs b/src/app/commands.rs index fd345975..f17920dc 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -970,7 +970,14 @@ impl H7CAD { } } - // ── Break ──────────────────────────────────────────────────────── + // ── Break / Join ───────────────────────────────────────────────── + "JOIN"|"J" => { + use crate::modules::home::modify::join::JoinCommand; + let cmd = JoinCommand::new(); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + "BREAK"|"BR" => { use crate::modules::home::modify::break_cmd::BreakInteractiveCommand; let cmd = BreakInteractiveCommand::new(); @@ -1003,7 +1010,7 @@ impl H7CAD { "HELP"|"?" => { self.command_line.push_output( "Draw: LINE CIRCLE ARC PLINE RECT POLY POINT ELLIPSE SPLINE RAY XLINE HATCH | \ - Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK | \ + Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK JOIN | \ Array: ARRAY ARRAYRECT ARRAYPOLAR ARRAYPATH | \ Text: TEXT MTEXT LEADER MLEADER | \ Dimension: DIMLINEAR DIMANGULAR DIMRADIUS | \ diff --git a/src/command/mod.rs b/src/command/mod.rs index 635e3c80..1c620db7 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -97,6 +97,8 @@ pub enum CmdResult { Measurement(String), /// Break `handle` at points `p1` and `p2`; replace with computed fragments. BreakEntity { handle: Handle, p1: Vec3, p2: Vec3 }, + /// Attempt to join the given entities into fewer merged entities. + JoinEntities(Vec), } // ── Trait ───────────────────────────────────────────────────────────────── diff --git a/src/modules/home/modify/join.rs b/src/modules/home/modify/join.rs new file mode 100644 index 00000000..a3d67dbc --- /dev/null +++ b/src/modules/home/modify/join.rs @@ -0,0 +1,198 @@ +// 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::entities::{Arc as ArcEnt, Line as LineEnt}; +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 XZ 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.z as f32, 0.0); + let e2 = Vec3::new(l.end.x as f32, l.end.z 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_deg, end_deg) — normalize CCW into [0, 360) + 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 mut merged_start = intervals[0][0]; + let mut merged_end = intervals[0][1]; + for &[s, e] in &intervals[1..] { + let span = ((e - merged_end) + 360.0) % 360.0; + let gap = ((s - merged_end) + 360.0) % 360.0; + let _ = span; + if gap > 1e-3 { return None; } // discontinuous + merged_end = e; + } + + let span = ((merged_end - merged_start) + 360.0) % 360.0; + let handles: Vec = arcs.iter().map(|(h, _)| *h).collect(); + + if (span - 360.0).abs() < 0.5 { + // 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) +} diff --git a/src/modules/home/modify/mod.rs b/src/modules/home/modify/mod.rs index 582b5fc3..61abe818 100644 --- a/src/modules/home/modify/mod.rs +++ b/src/modules/home/modify/mod.rs @@ -3,6 +3,7 @@ pub mod break_cmd; pub mod copy; pub mod delete; pub mod explode; +pub mod join; mod extend; pub mod fillet; pub mod mirror;