diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 68fbe0fe..97a03e26 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -683,8 +683,12 @@ impl OpenCADStudio { } } let was_active = self.tabs[self.active_tab].active_cmd.is_some(); - let preserve_selection = - matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..)); + let preserve_selection = matches!( + &result, + CmdResult::Relaunch(..) + | CmdResult::Dispatch(..) + | CmdResult::SolidSubtract { .. } + ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; let preview_hidden = self.tabs[i] @@ -3251,6 +3255,15 @@ impl OpenCADStudio { return task; } + CmdResult::SolidSubtract { bases, cutters } => { + let task = self.solid_subtract(&bases, &cutters); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::HatcheditApply { handle, name, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index efc19f56..0138c9fc 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,13 +1,20 @@ use super::*; fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { - let selected = app.tabs[tab].scene.selected_handles_in_order(); - (selected.len() == 1 - && matches!( - app.tabs[tab].scene.document.get_entity(selected[0]), - Some(acadrust::EntityType::Solid3D(_)) - )) - .then_some(selected[0]) + let scene = &app.tabs.get(tab)?.scene; + let selected = scene.selected_handles_in_order(); + let [handle] = selected.as_slice() else { + return None; + }; + + if matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + Some(*handle) + } else { + None + } } impl OpenCADStudio { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 1a12febf..065f2e62 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -934,13 +934,57 @@ impl OpenCADStudio { } // ── Solid booleans ───────────────────────────────────────────── - "UNION" | "SUBTRACT" | "INTERSECT" => { + "UNION" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { - return Some(self.solid_boolean(op)); + let solid_count = { + let scene = &self.tabs[i].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .take(2) + .count() + }; + if solid_count < 2 { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + return Some(self.solid_boolean(op)); + } } } + "SUBTRACT" => { + use crate::modules::model::boolean_cmd::SubtractCommand; + let bases = { + let scene = &self.tabs[i].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .collect() + }; + self.tabs[i].scene.deselect_all(); + let subtract = SubtractCommand::new(bases); + self.command_line.push_info(&subtract.prompt()); + self.tabs[i].active_cmd = Some(Box::new(subtract)); + } + // INTERFERE — non-destructive intersect: solid from the overlap. "INTERFERE" => { return Some(self.solid_interfere()); diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 1be644f6..2fe32100 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -248,6 +248,80 @@ impl super::OpenCADStudio { Task::none() } + pub(super) fn solid_subtract( + &mut self, + bases: &[Handle], + cutters: &[Handle], + ) -> Task { + let i = self.active_tab; + let valid_solid = |handle: &Handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_)) + ) + }; + let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_solid).collect(); + let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_solid).collect(); + cutter_handles.retain(|handle| !base_handles.contains(handle)); + + let mut operands = base_handles.clone(); + operands.extend(cutter_handles.iter().copied()); + self.tabs[i].scene.restore_solid_models(&operands); + base_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + cutter_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + if base_handles.is_empty() || cutter_handles.is_empty() { + self.command_line.push_error( + crate::t!("SUBTRACT: select at least one base solid and one cutter solid.") + .as_ref(), + ); + return Task::none(); + } + + let mut result = self.tabs[i].scene.solid_models[&base_handles[0]].clone(); + for handle in &base_handles[1..] { + let operand = &self.tabs[i].scene.solid_models[handle]; + let Some(combined) = solid_model::boolean(Bool::Union, &result, operand) else { + self.command_line.push_error( + crate::t!("SUBTRACT failed while combining the base solids.").as_ref(), + ); + return Task::none(); + }; + result = combined; + } + for handle in &cutter_handles { + let operand = &self.tabs[i].scene.solid_models[handle]; + let Some(difference) = solid_model::boolean(Bool::Subtract, &result, operand) else { + self.command_line.push_error( + crate::t!("SUBTRACT failed while removing the selected solids.").as_ref(), + ); + return Task::none(); + }; + result = difference; + } + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line + .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); + return Task::none(); + } + + operands = base_handles; + operands.extend(cutter_handles); + self.push_undo_snapshot(i, "SUBTRACT"); + self.tabs[i].scene.erase_entities(&operands); + let mut entity = Solid3D::new(); + entity.wires = solid_model::edge_wires(&result); + let history = solid_history::brep_op(&result); + let handle = self.add_solid_model(EntityType::Solid3D(entity), result, history); + self.tabs[i].scene.deselect_all(); + if !handle.is_null() { + self.tabs[i].scene.select_entity(handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + Task::none() + } + /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The /// kept half is the intersection of the solid with a half-space box, reusing diff --git a/src/command.rs b/src/command.rs index bf5b43d3..0ed4a77f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1465,6 +1465,10 @@ pub enum CmdResult { value: f64, fillet: bool, }, + SolidSubtract { + bases: Vec, + cutters: Vec, + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index da1bb10b..ff607b81 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -1,6 +1,7 @@ -// Boolean operations on 3D solids. The actual CSG runs in -// `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the -// cached B-reps; this module just names the operations. +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult, SelectionEntity}; /// Which boolean operation to apply to selected solids. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -21,6 +22,86 @@ impl BoolOp { } } +enum SubtractStep { + Bases, + Cutters, +} + +pub struct SubtractCommand { + step: SubtractStep, + bases: Vec, + selected: Vec, +} + +impl SubtractCommand { + pub fn new(bases: Vec) -> Self { + let step = if bases.is_empty() { + SubtractStep::Bases + } else { + SubtractStep::Cutters + }; + Self { + step, + bases, + selected: Vec::new(), + } + } +} + +impl CadCommand for SubtractCommand { + fn name(&self) -> &'static str { + "SUBTRACT" + } + + fn prompt(&self) -> String { + match self.step { + SubtractStep::Bases => crate::t!("SUBTRACT Select base solids, then press Enter:") + .into_owned(), + SubtractStep::Cutters => { + crate::t!("SUBTRACT Select solids to subtract, then press Enter:").into_owned() + } + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.selected.is_empty() { + return CmdResult::NeedPoint; + } + match self.step { + SubtractStep::Bases => { + self.bases = std::mem::take(&mut self.selected); + self.step = SubtractStep::Cutters; + CmdResult::DeselectAndContinue + } + SubtractStep::Cutters => CmdResult::SolidSubtract { + bases: std::mem::take(&mut self.bases), + cutters: std::mem::take(&mut self.selected), + }, + } + } + + fn is_selection_gathering(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected = entities + .into_iter() + .filter_map(|entity| { + matches!(entity.entity, EntityType::Solid3D(_)).then_some(entity.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["UNION", "SUBTRACT", "INTERSECT"]