diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 09eb0971..c06ec67e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3270,6 +3270,60 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + // ── EXTRUDE REGION ──────────────────────────────────────────── + CmdResult::ExtrudeRegion { + outline, + holes, + height, + color: _, + } => { + let all: Vec = + std::iter::once(outline).chain(holes.iter().copied()).collect(); + if let Some(handle) = all + .iter() + .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) + .copied() + { + self.reject_locked_edit(i, handle); + self.tabs[i].active_cmd = None; + return Task::none(); + } + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::scene::model::{solid_history, sweep_model}; + + let outline_ent = self.tabs[i].scene.document.get_entity(outline).cloned(); + let hole_ents: Vec = holes + .iter() + .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned()) + .collect(); + if let Some(outline_ent) = outline_ent { + if let Some(solid) = + sweep_model::extruded_region(&outline_ent, &hole_ents, height) + { + let history = solid_history::brep_op(&solid); + let pending = self.begin_undo(i, "EXTRUDE REGION", 1, true); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("EXTRUDE REGION: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + } else { + self.command_line.push_error(crate::t!("EXTRUDE REGION: could not build. Select a closed outline and coplanar closed hole loops.").as_ref()); + } + } else { + self.command_line + .push_error(crate::t!("EXTRUDE REGION: outline not found.").as_ref()); + } + 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::PresspullEntity { handle, pick, diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 4dca1006..eafd7770 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -809,6 +809,15 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + // ── EXTRUDE REGION ───────────────────────────────────────────── + "EXTRUDEREGION" | "EXTRUDE REGION" => { + use crate::modules::insert::solid3d_cmds::ExtrudeRegionCommand; + let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); + let cmd = ExtrudeRegionCommand::new(color); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + // ── OBJ import ─────────────────────────────────────────────── "IMPORTOBJ" | "OBJIMPORT" => { return Some(Task::done(Message::ObjImport)); diff --git a/src/command.rs b/src/command.rs index d4a2312b..a5b0565d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1487,6 +1487,14 @@ pub enum CmdResult { height: f64, color: [f32; 4], }, + /// Extrude an outline profile together with hole loops into one solid with + /// real through-holes, in a single kernel operation. + ExtrudeRegion { + outline: Handle, + holes: Vec, + height: f64, + color: [f32; 4], + }, /// Pull a closed profile or a planar solid face by a signed distance. PresspullEntity { handle: Handle, diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index db2898da..72620d4c 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -118,6 +118,149 @@ impl CadCommand for ExtrudeCommand { } } +// ── EXTRUDE REGION command ──────────────────────────────────────────────── +// +// Pick a closed outline, then any number of closed hole loops, Enter, then a +// height. One kernel op (sweep_model::extruded_region) instead of the +// extrude-each-then-SUBTRACT dance. + +pub struct ExtrudeRegionCommand { + step: ExtrudeRegionStep, + outline: acadrust::Handle, + holes: Vec, + anchor: DVec3, + direction: Option, + color: [f32; 4], +} + +#[derive(PartialEq)] +enum ExtrudeRegionStep { + PickOutline, + PickHoles, + Height, +} + +impl ExtrudeRegionCommand { + pub fn new(color: [f32; 4]) -> Self { + Self { + step: ExtrudeRegionStep::PickOutline, + outline: acadrust::Handle::NULL, + holes: Vec::new(), + anchor: DVec3::ZERO, + direction: None, + color, + } + } + + fn emit(&self, height: f64) -> CmdResult { + CmdResult::ExtrudeRegion { + outline: self.outline, + holes: self.holes.clone(), + height, + color: self.color, + } + } +} + +impl CadCommand for ExtrudeRegionCommand { + fn name(&self) -> &'static str { + "EXTRUDEREGION" + } + fn prompt(&self) -> String { + match self.step { + ExtrudeRegionStep::PickOutline => { + t!("EXTRUDE REGION Select outline profile:").into_owned() + } + ExtrudeRegionStep::PickHoles => t!( + "EXTRUDE REGION Select hole loop (%{count} picked, Enter when done):", + count = self.holes.len() + ) + .into_owned(), + ExtrudeRegionStep::Height => t!("EXTRUDE REGION Height:").into_owned(), + } + } + fn needs_entity_pick(&self) -> bool { + matches!( + self.step, + ExtrudeRegionStep::PickOutline | ExtrudeRegionStep::PickHoles + ) + } + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + fn set_entity_pick_direction(&mut self, direction: Option) { + if self.step == ExtrudeRegionStep::PickOutline { + self.direction = direction.and_then(DVec3::try_normalize); + } + } + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + match self.step { + ExtrudeRegionStep::PickOutline => { + self.outline = handle; + self.anchor = point; + self.step = ExtrudeRegionStep::PickHoles; + } + ExtrudeRegionStep::PickHoles => { + if handle != self.outline && !self.holes.contains(&handle) { + self.holes.push(handle); + } + } + ExtrudeRegionStep::Height => {} + } + CmdResult::NeedPoint + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.step == ExtrudeRegionStep::Height { + let height = self + .direction + .map(|direction| (pt - self.anchor).dot(direction)) + .unwrap_or_else(|| pt.distance(self.anchor)); + if !height.is_finite() || height.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } + return self.emit(height); + } + CmdResult::NeedPoint + } + fn wants_text_input(&self) -> bool { + self.step == ExtrudeRegionStep::Height + } + fn on_text_input(&mut self, text: &str) -> Option { + crate::entities::common::parse_typed_length(text) + .filter(|&h| h.abs() > 1e-6) + .map(|h| self.emit(h)) + } + fn on_enter(&mut self) -> CmdResult { + if self.step == ExtrudeRegionStep::PickHoles { + if self.outline.is_null() { + return CmdResult::Cancel; + } + self.step = ExtrudeRegionStep::Height; + return CmdResult::NeedPoint; + } + CmdResult::Cancel + } + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + (self.step == ExtrudeRegionStep::Height).then_some((self.anchor, self.direction?)) + } + fn dyn_spec(&self) -> Option { + (self.step == ExtrudeRegionStep::Height).then_some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(self.anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + Some((cursor - self.anchor).dot(self.direction?)) + } +} + // ── PRESSPULL command ───────────────────────────────────────────────────── pub struct PresspullCommand { @@ -483,6 +626,9 @@ pub fn empty_solid3d() -> EntityType { inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE", "THICKEN", "PRESSPULL"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["EXTRUDEREGION"] +}); inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 124e53fd..a2b2a956 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -105,6 +105,26 @@ pub fn extruded(entity: &EntityType, height: f64) -> Option { ) } +/// EXTRUDE REGION: extrude an `outline` together with any number of `holes` +/// into one solid with real through-holes, in a single kernel operation +/// (rather than extruding each and SUBTRACTing the holes by hand). +/// +/// `None` when the outline or a hole does not close, encloses nothing, or the +/// loops are not coplanar — the kernel refuses rather than approximating. Each +/// loop is taken in the outline's plane, which is the world-XY frame every +/// flat DXF profile already shares. +pub fn extruded_region(outline: &EntityType, holes: &[EntityType], height: f64) -> Option { + let outline = profile_of(outline)?; + let normal = outline.plane.normal()?; + let direction = [normal[0] * height, normal[1] * height, normal[2] * height]; + let mut loops = Vec::with_capacity(1 + holes.len()); + loops.push(outline.pieces); + for hole in holes { + loops.push(profile_of(hole)?.pieces); + } + brep::extrude_region(outline.plane, &loops, direction) +} + /// Signed distance of a drag along a profile's normal. pub fn projected_drag(entity: &EntityType, from: glam::DVec3, to: glam::DVec3) -> Option { let normal = entity_curve(entity)?.plane.normal()?;