diff --git a/Cargo.lock b/Cargo.lock index 27936037b..932743ea3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=f760c46d028d4ea9d4a9834b11e9a85d9d316ad3#f760c46d028d4ea9d4a9834b11e9a85d9d316ad3" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7f18223c66c1fde2e2a2c16e61259655d173bf4f#7f18223c66c1fde2e2a2c16e61259655d173bf4f" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index e8edda0d5..67c9ecdbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "f760c46d028d4ea9d4a9834b11e9a85d9d316ad3", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7f18223c66c1fde2e2a2c16e61259655d173bf4f", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 036993082..7367ecb8d 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "f760c46d028d4ea9d4a9834b11e9a85d9d316ad3", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7f18223c66c1fde2e2a2c16e61259655d173bf4f", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a5cadff8e..43b0740d8 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3803,6 +3803,102 @@ impl OpenCADStudio { self.refresh_properties(); } + CmdResult::ThickenEntities { handles, distance } => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + + if handles.is_empty() { + self.command_line + .push_output(crate::t!("No surfaces selected.").as_ref()); + return Task::none(); + } + if !distance.is_finite() { + self.command_line.push_error( + crate::t!("Requires numeric distance or two points.").as_ref(), + ); + return Task::none(); + } + if distance.abs() <= f64::EPSILON { + return Task::none(); + } + + use crate::modules::insert::solid3d_cmds::empty_solid3d; + let pending = self.begin_undo(i, "THICKEN", handles.len(), true); + let mut created = 0usize; + let mut failed = 0usize; + let mut self_intersections = 0usize; + for handle in handles { + let Some(acadrust::EntityType::Surface(surface)) = + self.tabs[i].scene.document.get_entity(handle) + else { + failed += 1; + continue; + }; + let kernel_distance = if surface.kind + == acadrust::entities::SurfaceKind::Revolved + { + -distance + } else { + distance + }; + let body = self.tabs[i] + .scene + .solid_models + .get(&handle) + .cloned() + .or_else(|| { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + }); + let Some(body) = body else { + failed += 1; + continue; + }; + let solid = match cadkernel::brep::thicken(&body, kernel_distance) { + Ok(solid) => solid, + Err(cadkernel::brep::ThickenError::SelfIntersection) => { + failed += 1; + self_intersections += 1; + continue; + } + Err(_) => { + failed += 1; + continue; + } + }; + let history = crate::scene::model::solid_history::brep_op(&solid); + if self + .add_solid_model(empty_solid3d(), solid, history) + .is_null() + { + failed += 1; + } else { + created += 1; + } + } + + if created > 0 { + self.tabs[i].dirty = true; + } + if self_intersections > 0 { + self.command_line.push_error(&crate::tf!( + "{} surface(s) cannot be thickened because the offset intersects itself.", + self_intersections + )); + } + if failed > 0 { + self.command_line.push_output(&crate::tf!( + "THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.", + created, failed + )); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.refresh_properties(); + } + CmdResult::PresspullPick { handle, point, offset, multiple: _ } => { self.presspull_pick(handle, point, offset); } @@ -5713,3 +5809,52 @@ fn resample_widths(source: &[(f64, f64)], count: usize) -> Vec<(f64, f64)> { }) .collect() } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod thicken_tests { + use super::*; + use acadrust::entities::{Surface, SurfaceKind}; + use cadkernel::geom2d::{Circle, Curve}; + use cadkernel::space::Plane; + + #[test] + fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let body = cadkernel::brep::planar_region(Plane::XY, &[ + vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })], + ]).unwrap(); + let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body); + assert!(!source.is_null()); + let i = app.active_tab; + let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic))); + let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); + let before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 }); + assert_eq!(app.tabs[i].history.undo_stack.len(), before); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 }); + assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); + let result = app.tabs[i].scene.document.entities().find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }).unwrap(); + assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); + assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); + let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume; + let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap(); + let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); + let mut restored = crate::scene::Scene::new(); + restored.document = document; + restored.restore_solid_models(&[result]); + let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume; + assert!((expected - actual).abs() < 1e-8); + app.undo_active_tab(); + assert!(app.tabs[i].scene.document.get_entity(result).is_none()); + assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none()); + assert!(app.tabs[i].scene.document.get_entity(source).is_some()); + assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); + app.redo_active_tab(); + assert!(app.tabs[i].scene.document.get_entity(result).is_some()); + assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); + assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); + } +} diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 84b0d3a05..908b02960 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -734,7 +734,7 @@ impl OpenCADStudio { } // ── EXTRUDE ──────────────────────────────────────────────────── - "EXTRUDE" | "THICKEN" => { + "EXTRUDE" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; // A preselection becomes the complete source set; otherwise // the interactive command gathers any number of profiles. @@ -769,6 +769,19 @@ impl OpenCADStudio { } } + "THICKEN" => { + use crate::modules::insert::solid3d_cmds::ThickenCommand; + let selected = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, entity)| (handle, entity.clone())) + .collect(); + let command = ThickenCommand::new(selected); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "PRESSPULL" => { use crate::modules::insert::solid3d_cmds::PresspullCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); diff --git a/src/command.rs b/src/command.rs index 99d092712..e45da43de 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1518,6 +1518,11 @@ pub enum CmdResult { taper_angle: f64, color: [f32; 4], }, + /// Thicken one or more persistent surface bodies without consuming them. + ThickenEntities { + handles: Vec, + distance: f64, + }, /// Resolve a profile, bounded area, or solid face for PRESSPULL. PresspullPick { handle: Option, diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index e5cfce0fb..3f5788237 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -513,6 +513,172 @@ impl CadCommand for ExtrudeCommand { } } +// ── THICKEN command ──────────────────────────────────────────────────────── + +pub struct ThickenCommand { + step: ThickenStep, + handles: Vec, + first_point: Option, + last_distance: f64, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ThickenStep { + Pick, + Distance, + SecondPoint, +} + +fn thicken_default() -> &'static Mutex { + static DEFAULT: OnceLock> = OnceLock::new(); + DEFAULT.get_or_init(|| Mutex::new(0.0)) +} + +impl ThickenCommand { + pub fn new(preselection: Vec<(Handle, EntityType)>) -> Self { + let handles = preselection + .into_iter() + .filter_map(|(handle, entity)| { + matches!(entity, EntityType::Surface(_)).then_some(handle) + }) + .collect::>(); + Self { + step: if handles.is_empty() { + ThickenStep::Pick + } else { + ThickenStep::Distance + }, + handles, + first_point: None, + last_distance: *thicken_default() + .lock() + .unwrap_or_else(|error| error.into_inner()), + } + } + + fn finish(&mut self, distance: f64) -> CmdResult { + self.last_distance = distance; + *thicken_default() + .lock() + .unwrap_or_else(|error| error.into_inner()) = distance; + CmdResult::ThickenEntities { + handles: self.handles.clone(), + distance, + } + } +} + +impl CadCommand for ThickenCommand { + fn name(&self) -> &'static str { + "THICKEN" + } + + fn prompt(&self) -> String { + match self.step { + ThickenStep::Pick => t!("Select surfaces to thicken:").into_owned(), + ThickenStep::Distance => format!( + "{} <{}>:", + t!("Specify thickness"), + crate::entities::common::format_length(self.last_distance) + ), + ThickenStep::SecondPoint => { + t!("Specify second point:").into_owned() + } + } + } + + fn wants_text_input(&self) -> bool { + self.step == ThickenStep::Distance + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.step != ThickenStep::Distance { + return None; + } + let value = text.trim(); + if value.is_empty() { + return None; + } + Some(match crate::entities::common::parse_typed_length(value) { + Some(distance) if distance.is_finite() => self.finish(distance), + _ => CmdResult::ReportError( + t!("Requires numeric distance or two points.").into_owned(), + ), + }) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + ThickenStep::Distance => { + self.first_point = Some(point); + self.step = ThickenStep::SecondPoint; + CmdResult::NeedPoint + } + ThickenStep::SecondPoint => { + let distance = point.distance(self.first_point.unwrap_or(point)); + if distance.is_finite() { + self.finish(distance) + } else { + CmdResult::ReportError( + t!("Requires numeric distance or two points.").into_owned(), + ) + } + } + ThickenStep::Pick => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + ThickenStep::Pick if self.handles.is_empty() => { + CmdResult::Measurement(t!("No surfaces selected.").into_owned()) + } + ThickenStep::Pick => { + self.step = ThickenStep::Distance; + CmdResult::NeedPoint + } + ThickenStep::Distance => self.finish(self.last_distance), + ThickenStep::SecondPoint => CmdResult::ReportError( + t!("Requires numeric distance or two points.").into_owned(), + ), + } + } + + fn is_selection_gathering(&self) -> bool { + self.step == ThickenStep::Pick + } + + fn selection_forces_add(&self) -> bool { + self.step == ThickenStep::Pick + } + + fn inject_selection_entities(&mut self, entities: Vec) { + if self.step != ThickenStep::Pick { + return; + } + self.handles = entities + .into_iter() + .filter_map(|entry| { + matches!(entry.entity, EntityType::Surface(_)).then_some(entry.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_undo_step(&mut self) -> Option { + if self.step == ThickenStep::SecondPoint { + self.first_point = None; + self.step = ThickenStep::Distance; + Some(CmdResult::NeedPoint) + } else { + None + } + } +} + // ── PRESSPULL command ───────────────────────────────────────────────────── pub struct PresspullCommand { @@ -2701,3 +2867,29 @@ mod revolve_tests { } } inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); + +#[cfg(test)] +mod thicken_command_tests { + use super::*; + + #[test] + fn thicken_filters_selection_recovers_from_invalid_text_and_measures_two_points() { + let handle = Handle::new(42); + let mut command = ThickenCommand::new(vec![ + (Handle::new(1), EntityType::Line(acadrust::entities::Line::default())), + (handle, EntityType::Surface(acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane))), + ]); + assert!(!command.is_selection_gathering()); + assert_eq!(command.handles, vec![handle]); + assert!(matches!(command.on_text_input("bad"), Some(CmdResult::ReportError(_)))); + assert!(command.input_kind().wants_text()); + assert!(matches!(command.on_text_input("-2"), Some(CmdResult::ThickenEntities { distance: -2.0, .. }))); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(command.on_undo_step().is_some()); + let _ = command.on_point(DVec3::ZERO); + assert!(matches!(command.on_point(DVec3::new(3.0, 4.0, 0.0)), CmdResult::ThickenEntities { distance: 5.0, .. })); + let mut empty = ThickenCommand::new(Vec::new()); + assert!(empty.is_selection_gathering()); + assert!(matches!(empty.on_enter(), CmdResult::Measurement(_))); + } +}