Merge pull request #1177 from ramox81/codex/ncopy-command-parity

Copy picked nested objects with placement and array options

# Conflicts:
#	src/app/commands/blocks.rs
This commit is contained in:
Hakan Seven committed 2026-09-11 11:41:52 +03:00
commit 7886ffb973
5 files changed
+219 -45

No files matched your search

+12 -2
View File
@@ -1274,6 +1274,8 @@ impl OpenCADStudio {
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
let i = self.active_tab;
let preserve_commit_style = self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.preserve_commit_style());
let preserve_commit_layer = self.tabs[i]
.active_cmd
.as_ref()
@@ -1429,7 +1431,9 @@ impl OpenCADStudio {
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
for entity in entities {
if preserve_commit_layer {
if preserve_commit_style {
let _ = self.commit_entity_handle_preserve_style(entity);
} else if preserve_commit_layer {
let _ = self.commit_entity_handle_preserve_layer(entity);
} else {
self.commit_entity(entity);
@@ -1452,7 +1456,13 @@ impl OpenCADStudio {
.all(|entity| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, label, entities.len(), delta_safe);
for entity in entities {
self.commit_entity(entity);
if preserve_commit_style {
let _ = self.commit_entity_handle_preserve_style(entity);
} else if preserve_commit_layer {
let _ = self.commit_entity_handle_preserve_layer(entity);
} else {
self.commit_entity(entity);
}
}
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
+7 -43
View File
@@ -654,49 +654,13 @@ impl OpenCADStudio {
}
}
// NCOPY — copy the nested objects of the selected block reference(s)
// into model space, keeping the block (a non-destructive extraction;
// every nested object is copied — the block is not exploded).
"NCOPY" | "NCOPYALL" => {
use crate::modules::draw::modify::explode::explode_entity;
if cmd == "NCOPY" && self.tabs[i].scene.selected.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let selection = SelectObjectsCommand::new("NCOPY");
self.command_line.push_info(&selection.prompt());
self.tabs[i].active_cmd = Some(Box::new(selection));
return Some(self.finish_dispatch(cmd));
}
let inserts: Vec<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_)))
.map(|(h, _)| *h)
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.collect();
if inserts.is_empty() {
self.command_line
.push_error(crate::t!("NCOPY: select a block reference first.").as_ref());
return Some(Task::none());
}
self.push_undo_snapshot(i, "NCOPY");
let mut n = 0usize;
for h in &inserts {
let nested = match self.tabs[i].scene.document.get_entity(*h).cloned() {
Some(ins) => explode_entity(&ins, &self.tabs[i].scene.document),
None => Vec::new(),
};
for e in nested {
self.tabs[i].scene.add_entity(e);
n += 1;
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(crate::tf!(
"NCOPY: copied {n} nested object(s) into model space (blocks kept)."
).as_ref());
let command = crate::modules::draw::modify::ncopy::NcopyCommand::new(
&self.tabs[i].scene.document,
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
_ => return None,
}
Some(self.finish_dispatch(cmd))
@@ -714,7 +678,7 @@ mod tests {
}
#[test]
fn ncopy_without_preselection_gathers_objects() {
fn ncopy_starts_nested_object_selection() {
let mut app = fresh_app();
let _ = app.run_command_line("NCOPY");
assert_eq!(
@@ -722,7 +686,7 @@ mod tests {
.active_cmd
.as_ref()
.map(|active| active.name()),
Some("SELECT")
Some("NCOPY")
);
}
+2
View File
@@ -1994,6 +1994,8 @@ impl InputKind {
}
pub trait CadCommand: Send {
/// Preserve source appearance for commands that extract existing entities.
fn preserve_commit_style(&self) -> bool { false }
/// Keep the layer already carried by entities committed by this command
/// instead of replacing it with the current drawing layer.
fn preserve_commit_layer(&self) -> bool {
+1
View File
@@ -13,6 +13,7 @@ pub mod join;
pub mod lengthen;
pub mod mirror;
pub mod mledit;
pub mod ncopy;
pub mod offset;
pub mod pedit;
pub mod block_edit;
+197
View File
@@ -0,0 +1,197 @@
use acadrust::{CadDocument, EntityType, Handle};
use glam::DVec3;
use std::collections::{HashMap, HashSet};
use crate::command::{CadCommand, CmdOption, CmdResult, EntityTransform};
use crate::entities::traits::EntityTypeOps;
use crate::scene::convert::acad_to_render::RenderObject;
enum Step { Select, Base, Target(DVec3), ArrayCount(DVec3), ArrayTarget(DVec3, usize, bool) }
pub struct NcopyCommand {
nested: HashMap<Handle, Vec<EntityType>>,
hit_paths: HashMap<Handle, Vec<Vec<[f64; 3]>>>,
selected: Vec<(Handle, usize)>,
step: Step,
multiple: bool,
placements: usize,
}
impl NcopyCommand {
pub fn new(document: &CadDocument) -> Self {
fn leaves(entity: &EntityType, document: &CadDocument, path: &mut HashSet<String>, output: &mut Vec<EntityType>) {
let EntityType::Insert(insert) = entity else { output.push(entity.clone()); return; };
let name = insert.block_name.to_ascii_uppercase();
if !path.insert(name.clone()) { return; }
for child in super::explode::explode_entity(entity, document) {
leaves(&child, document, path, output);
}
path.remove(&name);
}
let nested: HashMap<_, _> = document.entities().filter(|e| matches!(e, EntityType::Insert(_)))
.map(|e| {
let mut entities = Vec::new();
leaves(e, document, &mut HashSet::new(), &mut entities);
(e.common().handle, entities)
}).collect();
let hit_paths = nested.iter().map(|(&handle, entities)| {
let paths = entities.iter().map(|entity| {
if crate::entities::curve::entity_curve(entity).is_some() { return Vec::new(); }
match entity.to_render_entity(document).map(|render| render.object) {
Some(RenderObject::Dot(point)) => vec![point],
Some(RenderObject::Lines(points) | RenderObject::SegmentedLines(points)
| RenderObject::TaperedLines(points, _) | RenderObject::BoundaryLines { points, .. }) => points,
_ => Vec::new(),
}
}).collect();
(handle, paths)
}).collect();
Self { nested, hit_paths, selected: Vec::new(), step: Step::Select, multiple: false, placements: 0 }
}
fn place(&self, delta: DVec3) -> CmdResult {
if !delta.is_finite() { return CmdResult::NeedPoint; }
let copies = self.selected.iter().filter_map(|(h, index)| self.nested.get(h)?.get(*index)).cloned()
.map(|mut entity| {
crate::scene::view::dispatch::apply_transform(&mut entity, &EntityTransform::Translate(delta));
entity.common_mut().handle = Handle::NULL;
entity
}).collect();
if self.multiple { CmdResult::CommitEntities(copies) }
else { CmdResult::CommitEntitiesAndExit(copies) }
}
}
impl CadCommand for NcopyCommand {
fn name(&self) -> &'static str { "NCOPY" }
fn prompt(&self) -> String {
match self.step {
Step::Select => "Select nested objects:".into(),
Step::Base => "Specify base point or [Displacement/Multiple] <Displacement>:".into(),
Step::Target(_) => "Specify second point or [Array] <use first point as displacement>:".into(),
Step::ArrayCount(_) => "Enter number of items to array:".into(),
Step::ArrayTarget(_, _, false) => "Specify second point or [Fit]:".into(),
Step::ArrayTarget(_, _, true) => "Specify last point:".into(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Base => vec![CmdOption::new("Displacement", "D"), CmdOption::new("Multiple", "M")],
Step::Target(_) if self.multiple && self.placements > 0 => vec![CmdOption::new("Array", "A"), CmdOption::new("Exit", "E"), CmdOption::new("Undo", "U")],
Step::Target(_) => vec![CmdOption::new("Array", "A")],
Step::ArrayTarget(_, _, false) => vec![CmdOption::new("Fit", "F")],
_ => Vec::new(),
}
}
fn preserve_commit_style(&self) -> bool { true }
fn preserve_commit_layer(&self) -> bool { true }
fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::Select) }
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
if let Some(entities) = self.nested.get(&handle) {
let nearest = entities.iter().enumerate().filter_map(|(index, entity)| {
let distance = if let Some(curve) = crate::entities::curve::entity_curve(entity) {
let projected = curve.plane.project([point.x, point.y, point.z])?;
cadkernel::geom2d::closest_point(&curve.curve, projected).distance
.hypot(curve.plane.distance_to(point.to_array())?)
} else {
let path = self.hit_paths.get(&handle)?.get(index)?;
let point = cadkernel::space::Vec3::from(point.to_array());
if path.len() == 1 {
point.distance(cadkernel::space::Vec3::from(path[0]))
} else {
path.windows(2).filter(|segment| segment.iter().flatten().all(|v| v.is_finite()))
.map(|segment| point.distance_to_segment(segment[0].into(), segment[1].into()))
.min_by(f64::total_cmp)?
}
};
Some((index, distance))
}).min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((index, _)) = nearest {
if !self.selected.contains(&(handle, index)) { self.selected.push((handle, index)); }
}
}
CmdResult::NeedPoint
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::Select => CmdResult::NeedPoint,
Step::Base => { self.step = Step::Target(point); CmdResult::NeedPoint }
Step::Target(base) => {
if !point.is_finite() { return CmdResult::NeedPoint; }
self.placements += 1;
self.place(point - base)
}
Step::ArrayCount(_) => CmdResult::NeedPoint,
Step::ArrayTarget(base, count, fit) => {
let delta = (point - base) / if fit { (count - 1) as f64 } else { 1.0 };
if !delta.is_finite() { return CmdResult::NeedPoint; }
let mut copies = Vec::new();
for index in 0..count {
if let CmdResult::CommitEntities(mut entities) | CmdResult::CommitEntitiesAndExit(mut entities) = self.place(delta * index as f64) {
copies.append(&mut entities);
}
}
CmdResult::CommitEntitiesAndExit(copies)
}
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
Step::Select if !self.selected.is_empty() => { self.step = Step::Base; CmdResult::NeedPoint }
Step::Base => { self.step = Step::Target(DVec3::ZERO); CmdResult::NeedPoint }
Step::Target(base) if !self.multiple => self.place(base),
_ => CmdResult::Cancel,
}
}
fn point_step_accepts_keywords(&self) -> bool { matches!(self.step, Step::Base | Step::Target(_) | Step::ArrayTarget(..)) }
fn wants_text_input(&self) -> bool { !matches!(self.step, Step::Select) }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::ArrayCount(base) = self.step {
if let Ok(count) = text.trim().parse::<usize>() {
if (2..=32767).contains(&count) { self.step = Step::ArrayTarget(base, count, false); }
}
return Some(CmdResult::NeedPoint);
}
match text.trim().to_uppercase().as_str() {
"A" | "ARRAY" => {
if let Step::Target(base) = self.step { self.step = Step::ArrayCount(base); }
Some(CmdResult::NeedPoint)
}
"F" | "FIT" => {
if let Step::ArrayTarget(base, count, _) = self.step { self.step = Step::ArrayTarget(base, count, true); }
Some(CmdResult::NeedPoint)
}
"U" | "UNDO" if self.multiple && self.placements > 0 => {
self.placements -= 1;
Some(CmdResult::UndoDocument)
}
"E" | "EXIT" if self.multiple => Some(CmdResult::Cancel),
"M" | "MULTIPLE" if matches!(self.step, Step::Base) => { self.multiple = true; Some(CmdResult::NeedPoint) }
"D" | "DISPLACEMENT" if matches!(self.step, Step::Base) => { self.step = Step::Target(DVec3::ZERO); Some(CmdResult::NeedPoint) }
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::Line;
use acadrust::types::Vector3;
#[test]
fn placement_moves_only_geometry_and_keeps_source_style() {
let handle = Handle::new(7);
let mut line = Line::from_points(Vector3::ZERO, Vector3::new(2.0, 0.0, 0.0));
line.common.handle = handle;
line.common.layer = "DETAIL".into();
line.common.linetype = "DASHED".into();
let mut nested = HashMap::new();
nested.insert(handle, vec![EntityType::Line(line)]);
let command = NcopyCommand { nested, hit_paths: HashMap::new(), selected: vec![(handle, 0)],
step: Step::Base, multiple: false, placements: 0 };
let CmdResult::CommitEntitiesAndExit(entities) = command.place(DVec3::new(3.0, 4.0, 5.0))
else { panic!("expected copied entity") };
let EntityType::Line(line) = &entities[0] else { panic!("expected line") };
assert!(line.common.handle.is_null());
assert_eq!((line.common.layer.as_str(), line.common.linetype.as_str()), ("DETAIL", "DASHED"));
assert_eq!(line.start, Vector3::new(3.0, 4.0, 5.0));
assert_eq!(line.end, Vector3::new(5.0, 4.0, 5.0));
}
}