From 9ca2db82bf69b91f7c890754198be5fba6b4f3cd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:39:08 +0300 Subject: [PATCH] Add source-first joins with retained object identity --- src/app/command_driver.rs | 20 ++++++ src/app/commands/inquiry.rs | 5 +- src/command.rs | 2 + src/modules/draw/modify/join.rs | 114 +++++++++++++++++++++----------- 4 files changed, 101 insertions(+), 40 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 87635b15f..462f765f3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3263,6 +3263,26 @@ impl OpenCADStudio { self.command_line.push_info(&p); } } + CmdResult::JoinToSource { source, handles } => { + if self.reject_locked_edit(i, source) { return Task::none(); } + let joined = self.tabs[i].scene.document.get_entity(source).and_then(|entity| { + let candidates: Vec<_> = handles.iter().filter(|handle| **handle != source && !self.tabs[i].scene.is_layer_locked(**handle)) + .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).map(|entity| (*handle,entity))).collect(); + crate::modules::draw::modify::join::join_to_source(entity,&candidates) + }); + if let Some((replacement, consumed)) = joined { + self.push_undo_snapshot(i,"JOIN"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { *entity = replacement; } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.refresh_fill_model(source); + self.tabs[i].scene.bump_entities(&[(source,crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("JOIN: {} objects joined to source.",consumed.len())); + self.refresh_properties(); + } else { self.command_line.push_info("JOIN: no compatible objects joined to source."); } + self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + } CmdResult::JoinEntities(handles) => { if let Some(handle) = handles .iter() diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 08f920696..9f95c0ab1 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -218,7 +218,10 @@ impl OpenCADStudio { self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected)); return Some(task); } - let cmd = JoinCommand::new(); + let mut cmd = JoinCommand::new(); + if let Some(handle) = selected.first() { + if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { cmd = cmd.with_source(*handle, entity); } + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/command.rs b/src/command.rs index d69859462..35df1fbaa 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1475,6 +1475,8 @@ pub enum CmdResult { BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, /// Attempt to join the given entities into fewer merged entities. JoinEntities(Vec), + /// Join candidates into an explicitly selected source. + JoinToSource { source: Handle, handles: Vec }, /// Apply a polyline-edit operation to one entity; keep command active. PeditOp { handle: Handle, diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 9a101dea7..92c821c02 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -12,62 +12,98 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; // ── Command ──────────────────────────────────────────────────────────────── pub struct JoinCommand { + source: Option<(Handle, EntityType)>, + picked: Option, handles: Vec, - gathering: bool, } - impl JoinCommand { - pub fn new() -> Self { - Self { - handles: vec![], - gathering: true, - } + pub fn new() -> Self { Self { source: None, picked: None, handles: Vec::new() } } + pub fn with_source(mut self, handle: Handle, entity: EntityType) -> Self { + self.picked = Some(entity); self.on_entity_pick(handle, DVec3::ZERO); self } } - impl CadCommand for JoinCommand { - fn name(&self) -> &'static str { - "JOIN" - } - + fn name(&self) -> &'static str { "JOIN" } fn prompt(&self) -> String { - t!( - "JOIN Select objects to join (%{count} selected, Enter to apply):", - count = self.handles.len() - ) - .into_owned() - } - - 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: DVec3) -> CmdResult { - CmdResult::NeedPoint - } - - fn on_enter(&mut self) -> CmdResult { - if self.handles.len() < 2 { - return CmdResult::Cancel; + match self.source.as_ref().map(|(_, entity)| entity) { + None => "JOIN Select source object:".into(), + Some(EntityType::Line(_)) => "JOIN Select lines to join to source (Enter to apply):".into(), + Some(EntityType::Arc(_)) => "JOIN Select arcs to join to source (Enter to apply):".into(), + _ => "JOIN Select objects to join to source (Enter to apply):".into(), } - self.gathering = false; - CmdResult::JoinEntities(self.handles.clone()) + } + fn needs_entity_pick(&self) -> bool { self.source.is_none() } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + fn on_entity_pick(&mut self, handle: Handle, _: DVec3) -> CmdResult { + if handle.is_null() { return CmdResult::NeedPoint; } + if let Some(entity) = self.picked.take() { + let supported = match &entity { + EntityType::Line(_) | EntityType::Arc(_) => true, + EntityType::LwPolyline(p) => !p.is_closed, + EntityType::Polyline2D(p) => !p.is_closed(), + _ => false, + }; + if supported { self.source = Some((handle, entity)); } + } + CmdResult::NeedPoint + } + fn is_selection_gathering(&self) -> bool { self.source.is_some() } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.handles = handles.into_iter().filter(|handle| self.source.as_ref().is_none_or(|(source,_)| handle != source)).collect(); CmdResult::NeedPoint + } + fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { + let Some((source, _)) = self.source.as_ref() else { return CmdResult::Cancel; }; + if self.handles.is_empty() { return CmdResult::Cancel; } + CmdResult::JoinToSource { source: *source, handles: self.handles.clone() } } } -// ── Geometry ─────────────────────────────────────────────────────────────── +/// Join compatible candidates while preserving the source identity and style. +pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) -> Option<(EntityType, Vec)> { + let mut result = source.clone(); let mut consumed = Vec::new(); + loop { + let mut progress = false; + for (handle, candidate) in candidates { + if consumed.contains(handle) { continue; } + let next = match (&result, *candidate) { + (EntityType::Line(a), EntityType::Line(b)) => { + let point = |p: &Vector3| [p.x,p.y,p.z]; + cadkernel::space::source_join::join_collinear_lines([point(&a.start),point(&a.end)],[point(&b.start),point(&b.end)],JOIN_EPS).map(|span| { + let mut line = a.clone(); line.start = Vector3::new(span[0][0],span[0][1],span[0][2]); line.end = Vector3::new(span[1][0],span[1][1],span[1][2]); EntityType::Line(line) + }) + } + (EntityType::Arc(a), EntityType::Arc(b)) => { + let data = |a: &acadrust::entities::Arc| ([a.center.x,a.center.y,a.center.z],[a.normal.x,a.normal.y,a.normal.z],a.radius,[a.start_angle,a.end_angle]); + cadkernel::space::source_join::join_cocircular_arcs(data(a),data(b),JOIN_EPS).map(|span| { + if span[1]-span[0] >= std::f64::consts::TAU { + let mut circle = acadrust::entities::Circle::new(); circle.common = a.common.clone(); circle.center = a.center.clone(); circle.normal = a.normal.clone(); circle.radius = a.radius; circle.thickness = a.thickness; EntityType::Circle(circle) + } else { let mut arc = a.clone(); arc.start_angle = span[0]; arc.end_angle = span[1].rem_euclid(std::f64::consts::TAU); EntityType::Arc(arc) } + }) + } + (EntityType::LwPolyline(_) | EntityType::Polyline2D(_), _) => { + join_entities(&[(result.common().handle,&result),(*handle,*candidate)]).and_then(|(_,mut entities)| { + let entity = entities.pop()?; + if matches!(&entity,EntityType::Line(_)) { super::pedit::convert_to_polyline(&entity) } else { Some(entity) } + }) + } + _ => None, + }; + if let Some(mut entity) = next { + *entity.common_mut() = source.common().clone(); result = entity; consumed.push(*handle); progress = true; + } + } + if !progress { break; } + } + (!consumed.is_empty()).then_some((result,consumed)) +} /// Endpoint-match tolerance (model units). Segments split from a shared /// vertex meet exactly, so this only absorbs float noise.