diff --git a/Cargo.lock b/Cargo.lock index 326bb7474..a164e9172 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=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192" +source = "git+https://github.com/ramox81/cadkernel.git?rev=70aa5178c59d277b14c13dddffa502633d4cfbbc#70aa5178c59d277b14c13dddffa502633d4cfbbc" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 21a3ba4a8..8d0e8bdf5 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 = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "70aa5178c59d277b14c13dddffa502633d4cfbbc", 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 a5476d897..62d0d4198 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 = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "70aa5178c59d277b14c13dddffa502633d4cfbbc", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6c97312fe..b5afeffd9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -767,6 +767,19 @@ common = .slice-cut-at-ax-value-kept-the-half = SLICE: cut at __ocs_fmt_0__=__ocs_fmt_1__, kept the __ocs_fmt_2__ half. .slice-select-exactly-one-solid-created-this-se = SLICE: select exactly one solid created this session. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: the plane does not cross the solid on the kept side. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: fit a spline through __ocs_fmt_0__ points. .splinefit-need-at-least-3-points = SPLINEFIT: need at least 3 points. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: select a polyline to fit a spline through. @@ -2196,6 +2209,26 @@ draw = .slice-offset-along-x = SLICE offset along X: .slice-offset-along-y = SLICE offset along Y: .slice-offset-along-z = SLICE offset along Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Specify first point: .solid-specify-fourth-point = SOLID Specify fourth point: .solid-specify-second-point = SOLID Specify second point: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 69705fee8..2bf9cde7f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -754,6 +754,19 @@ common = .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__ konumunda kesildi, __ocs_fmt_2__ yarısı korundu. .slice-select-exactly-one-solid-created-this-se = SLICE: Bu oturumda oluşturulan tam bir katı seçin. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Düzlem, korunan tarafta katıyı kesmiyor. + .both = Her ikisi + .slice-select-at-least-one-target = SLICE: En az bir katı veya yüzey seçin. + .slice-invalid-plane = SLICE: Kesme düzlemi geçersiz. + .slice-side-point-on-plane = SLICE: Kesme düzleminden uzakta bir nokta seçin. + .slice-plane-misses-target = SLICE: Kesme düzlemi seçilen her nesneyi kesmiyor; hiçbir nesne değiştirilmedi. + .slice-unsupported-intersection = SLICE: Seçilen geometri desteklenmeyen bir yüzey kesişimi içeriyor; hiçbir nesne değiştirilmedi. + .slice-coincident-plane = SLICE: Kesme düzlemi kaynak geometriyle çakışıyor; hiçbir nesne değiştirilmedi. + .slice-topology-refused = SLICE: Seçilen topoloji güvenle bölünemedi; hiçbir nesne değiştirilmedi. + .slice-result-encode-failed = SLICE: Bir sonuç kodlanamadı; hiçbir nesne değiştirilmedi. + .slice-result-display-failed = SLICE: Bir sonuç eksiksiz görüntülenemedi; hiçbir nesne değiştirilmedi. + .slice-result-commit-failed = SLICE: Bir sonuç işlenemedi; tüm kaynak nesneler korundu. + .slice-retained-update-failed = SLICE: Korunan sonuç güncellenemedi. + .slice-created-results = SLICE: __ocs_fmt_0__ sonuç nesnesi oluşturuldu. .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ noktadan geçen bir spline uyduruldu. .splinefit-need-at-least-3-points = SPLINEFIT: En az 3 nokta gerekir. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: İçinden spline geçirilecek bir çoklu çizgi seçin. @@ -2177,6 +2190,26 @@ draw = .slice-offset-along-x = SLICE X boyunca ofset: .slice-offset-along-y = SLICE Y boyunca ofset: .slice-offset-along-z = SLICE Z boyunca ofset: + .slice-select-targets = SLICE Dilimlenecek katıları veya yüzeyleri seçin, ardından Enter'a basın: + .slice-first-point-or-method = SLICE Kesme düzlemindeki ilk noktayı belirtin veya [düzlemsel Nesne/Yüzey/Zekseni/Görünüm/XY/YZ/ZX/3nokta] <3nokta>: + .slice-first-plane-point = SLICE Kesme düzlemindeki ilk noktayı belirtin: + .slice-second-plane-point = SLICE Kesme düzlemindeki ikinci noktayı belirtin: + .slice-third-plane-point = SLICE Kesme düzlemindeki üçüncü noktayı belirtin: + .slice-point-on-plane = SLICE Kesme düzleminde bir nokta belirtin: + .slice-point-on-normal-axis = SLICE Düzlemin normal ekseninde bir nokta belirtin: + .slice-point-on-view-plane = SLICE Geçerli görünüm düzleminde bir nokta belirtin: + .slice-point-on-ucs-plane = SLICE Geçerli UCS düzleminde bir nokta belirtin: + .slice-select-planar-object = SLICE Düzlemsel bir eğri nesnesi seçin: + .slice-select-planar-surface = SLICE Düzlemsel bir yüzey seçin: + .slice-select-cutting-surface = SLICE Bir kesme yüzeyi seçin: + .slice-select-side-or-both = SLICE İstenen tarafta bir nokta belirtin veya [Her ikisi] : + .slice-select-object-or-both = SLICE Korunacak dilimlenmiş nesneyi seçin veya [Her ikisi] : + .slice-surface-not-planar = SLICE Seçilen yüzey düzlemsel değil. Düzlemsel bir yüzey seçin: + .slice-surface-not-supported = SLICE Seçilen yüzey desteklenen tek bir kesme yüzeyi tanımlamıyor. Başka bir yüzey seçin: + .slice-object-not-planar = SLICE Seçilen nesne bir düzlem tanımlamıyor. Düzlemsel bir eğri nesnesi seçin: + .slice-planar-object = düzlemsel Nesne + .slice-zaxis = Zekseni + .slice-three-points = 3nokta .solid-specify-first-point = SOLID İlk noktayı belirtin: .solid-specify-fourth-point = SOLID Dördüncü noktayı belirtin: .solid-specify-second-point = SOLID İkinci noktayı belirtin: diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b74e15fd..6ec45460b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -734,6 +734,8 @@ impl OpenCADStudio { CmdResult::Relaunch(..) | CmdResult::Dispatch(..) | CmdResult::SolidSubtract { .. } + | CmdResult::SliceEntities { .. } + | CmdResult::SliceSurfaceEntities { .. } ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; @@ -4128,6 +4130,32 @@ impl OpenCADStudio { return task; } + CmdResult::SliceEntities { + targets, + plane, + keep_point, + } => { + let task = self.solid_slice(&targets, plane, keep_point); + 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::SliceSurfaceEntities { + targets, + cutter, + keep_point, + } => { + let task = self.solid_slice_surface(&targets, *cutter, keep_point); + 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/draw.rs b/src/app/commands/draw.rs index 3a2b4a131..90eda4823 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1283,21 +1283,51 @@ impl OpenCADStudio { } } - // SLICE [X|Y|Z] [TOP|BOTTOM] — cut the selected solid with an - // axis-aligned plane, keeping the lower half by default. "SLICE" | "SL" => { - use crate::command::SelectThenKeywordCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenKeywordCommand::new( - "SLICE", - "SLICE cutting-plane axis [X / Y / Z] (add TOP/BOTTOM by typing):", - vec![ - ("X", "X", Some("SLICE offset along X:")), - ("Y", "Y", Some("SLICE offset along Y:")), - ("Z", "Z", Some("SLICE offset along Z:")), - ], - has_sel, - ); + use crate::modules::model::slice_cmd::SliceCommand; + let (targets, centre, radius, view_normal) = { + let scene = &mut self.tabs[i].scene; + let mut targets = 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(_) + | acadrust::EntityType::Surface(_) + ) + ) + }) + .collect::>(); + scene.restore_solid_models(&targets); + targets.retain(|handle| scene.solid_models.contains_key(handle)); + let bounds = targets + .iter() + .filter_map(|handle| { + crate::scene::model::solid_model::extent(&scene.solid_models[handle]) + }) + .fold(None::<([f64; 3], [f64; 3])>, |bounds, (low, high)| { + Some(match bounds { + None => (low, high), + Some((mut min, mut max)) => { + for axis in 0..3 { + min[axis] = min[axis].min(low[axis]); + max[axis] = max[axis].max(high[axis]); + } + (min, max) + } + }) + }); + let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { + let min = glam::DVec3::from_array(min); + let max = glam::DVec3::from_array(max); + ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) + }); + (targets, centre, radius, scene.active_gaze_dir().as_dvec3()) + }; + let c = SliceCommand::new(targets, view_normal, centre, radius); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1316,7 +1346,18 @@ impl OpenCADStudio { let value: Option = parts.get(val_idx).and_then(|s| s.parse().ok()); let keep_low = !parts.iter().any(|s| s == "TOP"); match value { - Some(v) => return Some(self.solid_slice(axis, v, keep_low)), + Some(v) => { + let (origin, x, normal) = match axis { + 0 => ([v, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), + 1 => ([0.0, v, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + _ => ([0.0, 0.0, v], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + }; + let plane = cadkernel::space::Plane::orthonormal(origin, x, normal) + .expect("fixed world axes define a plane"); + let side = glam::DVec3::from_array(origin) + + glam::DVec3::from_array(normal) * if keep_low { -1.0 } else { 1.0 }; + return Some(self.slice_selected(plane, Some(side))); + } None => self.command_line.push_info( crate::t!("Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)").as_ref(), ), diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a262c0ee3..813a54f6f 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -76,6 +76,35 @@ struct PreparedSubtract { result: Option<(Handle, EntityType, Body, PreparedSolidDisplay)>, } +struct PreparedSlice { + retained: Handle, + entity: EntityType, + body: Body, + display: PreparedSolidDisplay, + extra: Option<(EntityType, Body)>, +} + +enum ModelSliceTool { + Plane(cadkernel::space::Plane), + Surface(Body), +} + +impl ModelSliceTool { + fn side(&self, point: [f64; 3]) -> Option { + match self { + Self::Plane(plane) => plane.distance_to(point), + Self::Surface(surface) => cadkernel::brep::surface_side(surface, point), + } + } + + fn split(&self, body: &Body) -> Result, cadkernel::brep::Snag> { + match self { + Self::Plane(plane) => cadkernel::brep::slice_by_plane(body, *plane), + Self::Surface(surface) => cadkernel::brep::slice_by_surface(body, surface), + } + } +} + struct IntersectGroup { kind: UnionEntityKind, plane: Option, @@ -1273,77 +1302,248 @@ impl super::OpenCADStudio { 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 - /// the same boolean path as the modelling tools. - pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { + /// Split every selected solid or surface by an arbitrary plane. All + /// geometry, persistence records, and displays are prepared before the + /// first source entity is changed. + pub(super) fn solid_slice( + &mut self, + requested: &[Handle], + plane: cadkernel::space::Plane, + keep_point: Option, + ) -> Task { + self.solid_slice_with_tool(requested, ModelSliceTool::Plane(plane), keep_point) + } + + pub(super) fn solid_slice_surface( + &mut self, + requested: &[Handle], + cutter: Body, + keep_point: Option, + ) -> Task { + self.solid_slice_with_tool(requested, ModelSliceTool::Surface(cutter), keep_point) + } + + fn solid_slice_with_tool( + &mut self, + requested: &[Handle], + tool: ModelSliceTool, + keep_point: Option, + ) -> Task { let i = self.active_tab; - let handles = self.selected_solid_handles(); - if handles.len() != 1 { - self.command_line - .push_error(crate::t!("SLICE: select exactly one solid created this session.").as_ref()); + let mut handles = requested + .iter() + .copied() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_) | EntityType::Surface(_)) + ) + }) + .collect::>(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + if handles.is_empty() { + self.command_line.push_error( + crate::t!("SLICE: select at least one solid or surface.").as_ref(), + ); return Task::none(); } - let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - // Bounding box from the solid's edge wires. - let Some((min, max)) = solid_model::extent(&solid) else { - self.command_line - .push_error(crate::t!("SLICE: could not determine the solid's extent.").as_ref()); - return Task::none(); - }; - // Generous margin so the box fully spans the solid in the free axes. - let m = [ - (max[0] - min[0]).max(1.0), - (max[1] - min[1]).max(1.0), - (max[2] - min[2]).max(1.0), - ]; - let mut lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]]; - let mut hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]]; - if keep_low { - hi[axis] = value; + + let keep_positive = if let Some(point) = keep_point { + let tolerance = cadkernel::brep::operation_tolerance( + &handles + .iter() + .map(|handle| &self.tabs[i].scene.solid_models[handle]) + .collect::>(), + ); + let Some(distance) = tool.side(point.to_array()) else { + self.command_line + .push_error(crate::t!("SLICE: the cutting plane is invalid.").as_ref()); + return Task::none(); + }; + if distance.abs() <= tolerance { + self.command_line.push_error( + crate::t!("SLICE: choose a point away from the cutting plane.").as_ref(), + ); + return Task::none(); + } + Some(distance > 0.0) } else { - lo[axis] = value; - } - if hi[axis] <= lo[axis] { - self.command_line - .push_error(crate::t!("SLICE: the plane does not cross the solid on the kept side.").as_ref()); - return Task::none(); - } - let center = [ - (lo[0] + hi[0]) / 2.0, - (lo[1] + hi[1]) / 2.0, - (lo[2] + hi[2]) / 2.0, - ]; - let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]); - let Some(result) = halfspace - .as_ref() - .and_then(|half| solid_model::boolean(Bool::Intersect, &solid, half)) - else { - self.command_line - .push_error(crate::t!("SLICE failed — the plane may not cross the solid.").as_ref()); - return Task::none(); + None }; + + let mut prepared = Vec::with_capacity(handles.len()); + for handle in &handles { + let body = &self.tabs[i].scene.solid_models[handle]; + let result = match tool.split(body) { + Ok(Some(result)) => result, + Ok(None) => { + self.command_line.push_error( + crate::t!("SLICE: the cutting plane does not cross every selected object; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("SLICE: the selected geometry has an unsupported surface intersection; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("SLICE: the cutting plane is coincident with source geometry; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("SLICE: the selected topology could not be split safely; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + }; + let (body, extra_body) = match keep_positive { + Some(true) => (result.positive, None), + Some(false) => (result.negative, None), + None => (result.negative, Some(result.positive)), + }; + let Some(source) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + return Task::none(); + }; + let Some(entity) = entity_with_boolean_body(source.clone(), &body) else { + self.command_line.push_error( + crate::t!("SLICE: a result could not be encoded; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(*handle, &body) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("SLICE: a result could not be displayed completely; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + let extra = match extra_body { + Some(extra_body) => { + let mut extra_entity = match entity_with_boolean_body(source, &extra_body) { + Some(entity) => entity, + None => { + self.command_line.push_error( + crate::t!("SLICE: a result could not be encoded; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + }; + extra_entity.common_mut().handle = Handle::NULL; + Some((extra_entity, extra_body)) + } + None => None, + }; + prepared.push(PreparedSlice { + retained: *handle, + entity, + body, + display, + extra, + }); + } + self.push_undo_snapshot(i, "SLICE"); - self.tabs[i].scene.erase_entities(&handles); - let mut s3d = Solid3D::new(); - s3d.wires = solid_model::edge_wires(&result); - let history = solid_history::brep_op(&result); - let handle = self.add_solid_model(EntityType::Solid3D(s3d), result, history); + let mut created = Vec::new(); + for item in &prepared { + let Some((entity, body)) = &item.extra else { + continue; + }; + let handle = match entity { + EntityType::Solid3D(_) => self.add_solid_model_preserving_style( + entity.clone(), + body.clone(), + solid_history::brep_op(body), + ), + EntityType::Surface(_) => { + self.add_surface_model_preserving_style(entity.clone(), body.clone()) + } + _ => Handle::NULL, + }; + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + self.command_line.push_error( + crate::t!("SLICE: a result could not be committed; all source objects were retained.") + .as_ref(), + ); + return Task::none(); + } + created.push(handle); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + let mut retained = Vec::with_capacity(prepared.len()); + for item in prepared { + self.tabs[i].scene.delete_solid_history(item.retained); + if !self.tabs[i].scene.update_entity(item.entity) { + self.command_line.push_error( + crate::t!("SLICE: a retained result could not be updated.").as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(item.retained), + Some(EntityType::Solid3D(_)) + ) + { + self.tabs[i] + .scene + .create_solid_history(item.retained, solid_history::brep_op(&item.body)); + } + self.tabs[i].scene.register_prepared_solid_model( + item.retained, + item.body, + item.display, + ); + retained.push(item.retained); + } + + retained.extend(created); self.tabs[i].scene.deselect_all(); - if !handle.is_null() { - self.tabs[i].scene.select_entity(handle, false); + for handle in &retained { + self.tabs[i].scene.select_entity(*handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); - let ax = ["X", "Y", "Z"][axis]; - self.command_line.push_output(crate::tf!( - "SLICE: cut at {ax}={value}, kept the {} half.", - if keep_low { "lower" } else { "upper" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SLICE: created %{count} result object(s).", + count = retained.len() + ) + .as_ref(), + ); Task::none() } + pub(super) fn slice_selected( + &mut self, + plane: cadkernel::space::Plane, + keep_point: Option, + ) -> Task { + let handles = self.tabs[self.active_tab].scene.selected_handles_in_order(); + self.solid_slice(&handles, plane, keep_point) + } + /// INTERFERE — create a solid from the overlap of the two selected solids, /// leaving the originals in place (a non-destructive boolean intersect). pub(super) fn solid_interfere(&mut self) -> Task { diff --git a/src/command.rs b/src/command.rs index 25ccfb027..fbe096345 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1559,6 +1559,20 @@ pub enum CmdResult { cutters: Vec, convert_meshes: bool, }, + /// Split every selected solid or surface with one arbitrary plane. + /// `keep_point == None` retains both sides; otherwise it selects the side + /// containing that WCS point. + SliceEntities { + targets: Vec, + plane: cadkernel::space::Plane, + keep_point: Option, + }, + /// Split selected solids or surfaces with one selected analytic sheet. + SliceSurfaceEntities { + targets: Vec, + cutter: Box, + keep_point: Option, + }, /// 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/locale_catalog.rs b/src/locale_catalog.rs index eda54347e..946940da2 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2346,11 +2346,44 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SLICE offset along X:" => Some(("draw", "slice-offset-along-x")), "SLICE offset along Y:" => Some(("draw", "slice-offset-along-y")), "SLICE offset along Z:" => Some(("draw", "slice-offset-along-z")), + "SLICE Select solids or surfaces to slice, then press Enter:" => Some(("draw", "slice-select-targets")), + "SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:" => Some(("draw", "slice-first-point-or-method")), + "SLICE Specify first point on slicing plane:" => Some(("draw", "slice-first-plane-point")), + "SLICE Specify second point on slicing plane:" => Some(("draw", "slice-second-plane-point")), + "SLICE Specify third point on slicing plane:" => Some(("draw", "slice-third-plane-point")), + "SLICE Specify a point on the slicing plane:" => Some(("draw", "slice-point-on-plane")), + "SLICE Specify a point on the plane's normal axis:" => Some(("draw", "slice-point-on-normal-axis")), + "SLICE Specify a point on the current view plane:" => Some(("draw", "slice-point-on-view-plane")), + "SLICE Specify a point on the current UCS plane:" => Some(("draw", "slice-point-on-ucs-plane")), + "SLICE Select a planar curve object:" => Some(("draw", "slice-select-planar-object")), + "SLICE Select a planar surface:" => Some(("draw", "slice-select-planar-surface")), + "SLICE Select a cutting surface:" => Some(("draw", "slice-select-cutting-surface")), + "SLICE Specify a point on the desired side or [Both] :" => Some(("draw", "slice-select-side-or-both")), + "SLICE Select a sliced object to keep or [Both] :" => Some(("draw", "slice-select-object-or-both")), + "SLICE The selected surface is not planar. Select a planar surface:" => Some(("draw", "slice-surface-not-planar")), + "SLICE The selected surface does not define one supported cutting sheet. Select another surface:" => Some(("draw", "slice-surface-not-supported")), + "SLICE The selected object does not define a plane. Select a planar curve object:" => Some(("draw", "slice-object-not-planar")), + "planar Object" => Some(("draw", "slice-planar-object")), + "Zaxis" => Some(("draw", "slice-zaxis")), + "3points" => Some(("draw", "slice-three-points")), + "Both" => Some(("common", "both")), "SLICE failed — the plane may not cross the solid." => Some(("common", "slice-failed-the-plane-may-not-cross-the-solid")), "SLICE: could not determine the solid's extent." => Some(("common", "slice-could-not-determine-the-solid-s-extent")), "SLICE: cut at {ax}={value}, kept the {} half." => Some(("common", "slice-cut-at-ax-value-kept-the-half")), "SLICE: select exactly one solid created this session." => Some(("common", "slice-select-exactly-one-solid-created-this-se")), "SLICE: the plane does not cross the solid on the kept side." => Some(("common", "slice-the-plane-does-not-cross-the-solid-on-th")), + "SLICE: select at least one solid or surface." => Some(("common", "slice-select-at-least-one-target")), + "SLICE: the cutting plane is invalid." => Some(("common", "slice-invalid-plane")), + "SLICE: choose a point away from the cutting plane." => Some(("common", "slice-side-point-on-plane")), + "SLICE: the cutting plane does not cross every selected object; no object was changed." => Some(("common", "slice-plane-misses-target")), + "SLICE: the selected geometry has an unsupported surface intersection; no object was changed." => Some(("common", "slice-unsupported-intersection")), + "SLICE: the cutting plane is coincident with source geometry; no object was changed." => Some(("common", "slice-coincident-plane")), + "SLICE: the selected topology could not be split safely; no object was changed." => Some(("common", "slice-topology-refused")), + "SLICE: a result could not be encoded; no object was changed." => Some(("common", "slice-result-encode-failed")), + "SLICE: a result could not be displayed completely; no object was changed." => Some(("common", "slice-result-display-failed")), + "SLICE: a result could not be committed; all source objects were retained." => Some(("common", "slice-result-commit-failed")), + "SLICE: a retained result could not be updated." => Some(("common", "slice-retained-update-failed")), + "SLICE: created %{count} result object(s)." => Some(("common", "slice-created-results")), "SOLID Specify first point:" => Some(("draw", "solid-specify-first-point")), "SOLID Specify fourth point:" => Some(("draw", "solid-specify-fourth-point")), "SOLID Specify second point:" => Some(("draw", "solid-specify-second-point")), diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..d664d68f5 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -5,6 +5,7 @@ pub mod cylinder_cmd; pub mod edge_cmd; pub mod polysolid_cmd; pub mod primitive_cmd; +pub mod slice_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; diff --git a/src/modules/model/slice_cmd.rs b/src/modules/model/slice_cmd.rs new file mode 100644 index 000000000..b64909bb8 --- /dev/null +++ b/src/modules/model/slice_cmd.rs @@ -0,0 +1,510 @@ +use acadrust::{EntityType, Handle}; +use cadkernel::space::Plane; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, SelectionEntity, WorkingPlane}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy)] +enum PlaneKind { + Xy, + Yz, + Zx, + View, +} + +enum Step { + Targets, + FirstPoint, + ThreeFirst, + ThreeSecond(DVec3), + ThirdPoint(DVec3, DVec3), + AxisFirst, + AxisSecond(DVec3), + PlanePoint(PlaneKind), + PickObject, + PickSurface, + Side(Plane), + SurfaceSide, +} + +/// Interactive front-end for plane-based SLICE. +pub struct SliceCommand { + step: Step, + targets: Vec, + selected: Vec, + working: WorkingPlane, + view_normal: DVec3, + preview_center: DVec3, + preview_radius: f64, + picked: Option, + surface_tool: Option, + notice: Option<&'static str>, +} + +impl SliceCommand { + pub fn new( + targets: Vec, + view_normal: DVec3, + preview_center: DVec3, + preview_radius: f64, + ) -> Self { + let step = if targets.is_empty() { Step::Targets } else { Step::FirstPoint }; + Self { + step, + targets, + selected: Vec::new(), + working: WorkingPlane::default(), + view_normal: view_normal.normalize_or(DVec3::Z), + preview_center, + preview_radius: preview_radius.max(1.0), + picked: None, + surface_tool: None, + notice: None, + } + } + + fn plane(origin: DVec3, x: DVec3, normal: DVec3) -> Option { + Plane::orthonormal(origin.to_array(), x.to_array(), normal.to_array()) + } + + fn enter_side(&mut self, plane: Plane) -> CmdResult { + self.notice = None; + self.step = Step::Side(plane); + CmdResult::NeedPoint + } + + fn finish(&mut self, keep_point: Option) -> CmdResult { + let Step::Side(plane) = self.step else { + return CmdResult::NeedPoint; + }; + CmdResult::SliceEntities { + targets: std::mem::take(&mut self.targets), + plane, + keep_point, + } + } + + fn picked_plane(&self) -> Option { + let entity = self.picked.as_ref()?; + if !matches!( + entity, + EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline(_) + ) { + return None; + } + crate::scene::model::presspull_model::profile_geometry(entity) + .map(|(plane, _, _)| plane) + } + + fn finish_surface(&mut self, keep_point: Option) -> CmdResult { + let Some(cutter) = self.surface_tool.take() else { + return CmdResult::NeedPoint; + }; + CmdResult::SliceSurfaceEntities { + targets: std::mem::take(&mut self.targets), + cutter: Box::new(cutter), + keep_point, + } + } + + fn plane_at(&self, point: DVec3, kind: PlaneKind) -> Option { + let (x, normal) = match kind { + PlaneKind::Xy => (self.working.x, self.working.z), + PlaneKind::Yz => (self.working.y, self.working.x), + PlaneKind::Zx => (self.working.z, self.working.y), + PlaneKind::View => { + let candidate = if self.working.x.dot(self.view_normal).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + (candidate, self.view_normal) + } + }; + Self::plane(point, x, normal) + } +} + +impl CadCommand for SliceCommand { + fn name(&self) -> &'static str { + "SLICE" + } + + fn prompt(&self) -> String { + if let Some(notice) = self.notice { + return crate::t!(notice).into_owned(); + } + match self.step { + Step::Targets => crate::t!( + "SLICE Select solids or surfaces to slice, then press Enter:" + ) + .into_owned(), + Step::FirstPoint => crate::t!( + "SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:" + ) + .into_owned(), + Step::ThreeFirst => { + crate::t!("SLICE Specify first point on slicing plane:").into_owned() + } + Step::ThreeSecond(_) => { + crate::t!("SLICE Specify second point on slicing plane:").into_owned() + } + Step::ThirdPoint(_, _) => { + crate::t!("SLICE Specify third point on slicing plane:").into_owned() + } + Step::AxisFirst => { + crate::t!("SLICE Specify a point on the slicing plane:").into_owned() + } + Step::AxisSecond(_) => { + crate::t!("SLICE Specify a point on the plane's normal axis:").into_owned() + } + Step::PlanePoint(PlaneKind::View) => { + crate::t!("SLICE Specify a point on the current view plane:").into_owned() + } + Step::PlanePoint(_) => { + crate::t!("SLICE Specify a point on the current UCS plane:").into_owned() + } + Step::PickObject => crate::t!("SLICE Select a planar curve object:").into_owned(), + Step::PickSurface => crate::t!("SLICE Select a cutting surface:").into_owned(), + Step::Side(_) => crate::t!( + "SLICE Specify a point on the desired side or [Both] :" + ) + .into_owned(), + Step::SurfaceSide => crate::t!( + "SLICE Select a sliced object to keep or [Both] :" + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::FirstPoint => vec![ + CmdOption::new(crate::t!("planar Object").as_ref(), "O"), + CmdOption::new(crate::t!("Surface").as_ref(), "S"), + CmdOption::new(crate::t!("Zaxis").as_ref(), "Z"), + CmdOption::new(crate::t!("View").as_ref(), "V"), + CmdOption::new("XY", "XY"), + CmdOption::new("YZ", "YZ"), + CmdOption::new("ZX", "ZX"), + CmdOption::new(crate::t!("3points").as_ref(), "3"), + ], + Step::Side(_) | Step::SurfaceSide => { + vec![CmdOption::new(crate::t!("Both").as_ref(), "B")] + } + _ => Vec::new(), + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.working = plane; + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.notice = None; + match self.step { + Step::FirstPoint => { + self.step = Step::ThreeSecond(point); + CmdResult::NeedPoint + } + Step::ThreeFirst => { + self.step = Step::ThreeSecond(point); + CmdResult::NeedPoint + } + Step::ThreeSecond(first) => { + self.step = Step::ThirdPoint(first, point); + CmdResult::NeedPoint + } + Step::ThirdPoint(first, second) => { + let x = second - first; + let normal = x.cross(point - first); + Self::plane(first, x, normal) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)) + } + Step::AxisFirst => { + self.step = Step::AxisSecond(point); + CmdResult::NeedPoint + } + Step::AxisSecond(origin) => { + let normal = point - origin; + let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + Self::plane(origin, x, normal) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)) + } + Step::PlanePoint(kind) => self + .plane_at(point, kind) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)), + Step::Side(_) => self.finish(Some(point)), + Step::SurfaceSide => self.finish_surface(Some(point)), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Targets if !self.selected.is_empty() => { + self.targets = std::mem::take(&mut self.selected); + self.step = Step::FirstPoint; + CmdResult::DeselectAndContinue + } + Step::FirstPoint => { + self.step = Step::ThreeFirst; + CmdResult::NeedPoint + } + Step::Side(_) => self.finish(None), + Step::SurfaceSide => self.finish_surface(None), + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + Some(match self.step { + Step::FirstPoint => match keyword.as_str() { + "O" | "OBJECT" => { + self.step = Step::PickObject; + CmdResult::NeedPoint + } + "S" | "SURFACE" => { + self.step = Step::PickSurface; + CmdResult::NeedPoint + } + "Z" | "ZAXIS" => { + self.step = Step::AxisFirst; + CmdResult::NeedPoint + } + "V" | "VIEW" => { + self.step = Step::PlanePoint(PlaneKind::View); + CmdResult::NeedPoint + } + "XY" => { + self.step = Step::PlanePoint(PlaneKind::Xy); + CmdResult::NeedPoint + } + "YZ" => { + self.step = Step::PlanePoint(PlaneKind::Yz); + CmdResult::NeedPoint + } + "ZX" | "XZ" => { + self.step = Step::PlanePoint(PlaneKind::Zx); + CmdResult::NeedPoint + } + "3" | "3P" | "3POINTS" => { + self.step = Step::ThreeFirst; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + }, + Step::Side(_) if matches!(keyword.as_str(), "" | "B" | "BOTH") => self.finish(None), + Step::SurfaceSide if matches!(keyword.as_str(), "" | "B" | "BOTH") => { + self.finish_surface(None) + } + _ => CmdResult::NeedPoint, + }) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstPoint | Step::Side(_) | Step::SurfaceSide) + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, Step::Targets) + } + + fn selection_forces_add(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected = entities + .into_iter() + .filter_map(|item| { + matches!(item.entity, EntityType::Solid3D(_) | EntityType::Surface(_)) + .then_some(item.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + + fn needs_entity_pick(&self) -> bool { + matches!( + self.step, + Step::PickObject | Step::PickSurface | Step::SurfaceSide + ) + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickObject | Step::PickSurface) + } + + fn entity_pick_uses_surface_point(&self) -> bool { + matches!(self.step, Step::SurfaceSide) + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, _handle: Handle, _point: DVec3) -> CmdResult { + if matches!(self.step, Step::SurfaceSide) { + return self.finish_surface(Some(_point)); + } + let surface_only = matches!(self.step, Step::PickSurface); + if surface_only { + let body = self.picked.as_ref().and_then(|entity| match entity { + EntityType::Surface(surface) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } + _ => None, + }); + if let Some(body) = body { + if let Some(plane) = planar_body_plane(&body) { + return self.enter_side(plane); + } + if body.face_keys().count() == 1 { + self.surface_tool = Some(body); + self.notice = None; + self.step = Step::SurfaceSide; + return CmdResult::NeedPoint; + } + } + self.notice = Some( + "SLICE The selected surface does not define one supported cutting sheet. Select another surface:", + ); + return CmdResult::NeedPoint; + } + match self.picked_plane() { + Some(plane) => self.enter_side(plane), + None => { + self.notice = Some( + "SLICE The selected object does not define a plane. Select a planar curve object:", + ); + CmdResult::NeedPoint + } + } + } + + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let mut wires = Vec::new(); + let plane = match self.step { + Step::ThirdPoint(first, second) => { + wires.push(WireModel::solid_f64( + "slice_triangle".into(), + vec![ + first.to_array(), + second.to_array(), + point.to_array(), + first.to_array(), + ], + WireModel::CYAN, + false, + )); + Self::plane(first, second - first, (second - first).cross(point - first)) + } + Step::AxisSecond(origin) => { + wires.push(WireModel::solid_f64( + "slice_normal".into(), + vec![origin.to_array(), point.to_array()], + WireModel::CYAN, + false, + )); + let normal = point - origin; + let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + Self::plane(origin, x, normal) + } + Step::PlanePoint(kind) => self.plane_at(point, kind), + Step::Side(plane) => Some(plane), + _ => None, + }; + if let Some(plane) = plane { + wires.extend(plane_grid( + plane, + self.preview_center, + self.preview_radius, + )); + } + wires + } +} + +fn planar_body_plane(body: &cadkernel::brep::Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let normal = DVec3::from_array(first.normal()?); + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + faces + .all(|face| { + let Some(profile) = cadkernel::brep::planar_face_profile(body, face) else { + return false; + }; + profile.plane.normal().is_some_and(|other| { + normal.dot(DVec3::from_array(other)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|gap| gap.abs() <= tolerance * 4.0) + }) + }) + .then_some(first) +} + +fn plane_grid(plane: Plane, centre: DVec3, radius: f64) -> Vec { + let normal = DVec3::from_array(plane.normal().unwrap_or([0.0, 0.0, 1.0])); + let projected = centre + - normal + * plane + .distance_to(centre.to_array()) + .unwrap_or_default(); + let x = DVec3::from_array(plane.x_axis); + let y = DVec3::from_array(plane.y_axis); + let color = [0.2, 0.55, 1.0, 1.0]; + (-5..=5) + .flat_map(|index| { + let offset = radius * index as f64 / 5.0; + [ + WireModel::solid_f64( + format!("slice_grid_x_{index}"), + vec![ + (projected + x * offset - y * radius).to_array(), + (projected + x * offset + y * radius).to_array(), + ], + color, + false, + ), + WireModel::solid_f64( + format!("slice_grid_y_{index}"), + vec![ + (projected + y * offset - x * radius).to_array(), + (projected + y * offset + x * radius).to_array(), + ], + color, + false, + ), + ] + }) + .collect() +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SLICE", "SL"] +});