diff --git a/Cargo.lock b/Cargo.lock index df993e5a8..9bd3648bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a809cb32cc6d90fd0e9c4199c3b4977cd56b7fa8#a809cb32cc6d90fd0e9c4199c3b4977cd56b7fa8" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5a4c287236e03a23be4d5099a45b16346e9f9442#5a4c287236e03a23be4d5099a45b16346e9f9442" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 6a5e39342..1b2017efb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "a0f7d444f1607bc4b2c881060cbe7ea1014253cb", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a809cb32cc6d90fd0e9c4199c3b4977cd56b7fa8", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5a4c287236e03a23be4d5099a45b16346e9f9442", 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/src/app/command_driver.rs b/src/app/command_driver.rs index 713996c18..eec1705dc 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3589,42 +3589,178 @@ impl OpenCADStudio { } } // ── EXTRUDE ──────────────────────────────────────────────────── - CmdResult::ExtrudeEntity { - handle, - height, + CmdResult::ExtrudeEntities { + handles, + extent, + mode, + taper_angle, color: _, } => { - if self.reject_locked_edit(i, handle) { + let delete_sources = self.tabs[i].scene.document.header.delete_objects; + if handles.is_empty() + || handles + .iter() + .any(|handle| 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::command::{ExtrudeExtent, ExtrudeMode}; + use crate::modules::insert::solid3d_cmds::{ + empty_extruded_surface, empty_solid3d, + }; use crate::scene::model::sweep_model; - - let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); - if let Some(entity) = entity_opt { - if let Some(solid) = sweep_model::extruded(&entity, height) { - let history = crate::scene::model::solid_history::brep_op(&solid); - let pending = self.begin_undo(i, "EXTRUDE", 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: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } + let path = match extent { + ExtrudeExtent::Path(handle) => self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)), + _ => None, + }; + if matches!(extent, ExtrudeExtent::Path(_)) && path.is_none() { + self.command_line + .push_error(crate::t!("EXTRUDE: path entity not found.").as_ref()); + self.tabs[i].active_cmd = None; + return Task::none(); + } + let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true); + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + let mut failed = 0usize; + for handle in &handles { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + failed += 1; + continue; + }; + let Some((profile, closed)) = sweep_model::extrusion_profile_of(&entity) else { + failed += 1; + continue; + }; + let direction = match extent { + ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| { + glam::DVec3::new( + normal[0] * height, + normal[1] * height, + normal[2] * height, + ) + }), + ExtrudeExtent::Direction(direction) => Some(direction), + ExtrudeExtent::Path(_) => None, + }; + let path_direction = path + .as_ref() + .and_then(|(_, path)| sweep_model::straight_path_direction(path)) + .map(glam::DVec3::from_array); + // The creation mode controls closed profiles. Open curves + // always create sheet surfaces. + let creates_surface = matches!(mode, ExtrudeMode::Surface) || !closed; + let body = match (creates_surface, &path, direction) { + (false, Some((_, path)), _) => { + sweep_model::extruded_along_path(&entity, path, taper_angle) } + (false, None, Some(direction)) => { + sweep_model::extruded_direction( + &entity, + direction.to_array(), + taper_angle, + ) + } + (true, None, Some(direction)) => { + sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ) + } + (true, Some(_), _) => path_direction.and_then(|direction| { + sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ) + }), + _ => None, + }; + let Some(body) = body else { + failed += 1; + continue; + }; + let created = if creates_surface { + let direction = direction + .or(path_direction) + .unwrap_or(glam::DVec3::ZERO); + self.add_surface_model( + empty_extruded_surface(direction, taper_angle), + body, + ) } else { - self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref()); + let direction = direction.unwrap_or(glam::DVec3::ZERO); + let history = path + .as_ref() + .and_then(|(_, path)| { + sweep_model::sweep_history( + &entity, + path, + taper_angle, + profile.plane.origin, + ) + }) + .or_else(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body)); + self.add_solid_model(empty_solid3d(), body, history) + }; + if created.is_null() { + failed += 1; + } else { + created_handles.push(created); + if delete_sources { + consumed.push(*handle); + } + } + } + if delete_sources && !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + } + if !created_handles.is_empty() { + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!( + "EXTRUDE: created %{created} object(s); %{failed} source(s) could not be extruded.", + created = created_handles.len(), + failed = failed + ) + .as_ref(), + ); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); } } else { - self.command_line.push_error(crate::t!("EXTRUDE: entity not found.").as_ref()); + self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref()); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + self.refresh_properties(); } CmdResult::PresspullEntity { diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 0407c8e1e..ed44e08af 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -736,21 +736,30 @@ impl OpenCADStudio { // ── EXTRUDE ──────────────────────────────────────────────────── "EXTRUDE" | "THICKEN" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; - // If a single entity is already selected, skip the pick step. + // A preselection becomes the complete source set; otherwise + // the interactive command gathers any number of profiles. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); - if selected.len() == 1 { - let handle = selected[0].0; + if !selected.is_empty() { let mut cmd = ExtrudeCommand::new_named(cmd, color); - if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { - cmd.set_entity_pick_direction( - curve.plane.normal().map(glam::DVec3::from_array), - ); - cmd.on_entity_pick( - handle, - glam::DVec3::from_array(curve.plane.origin), - ); - } + let first_curve = selected + .iter() + .find_map(|(_, entity)| crate::entities::curve::entity_curve(entity)); + let anchor = first_curve + .as_ref() + .map(|curve| glam::DVec3::from_array(curve.plane.origin)) + .unwrap_or(glam::DVec3::ZERO); + let direction = first_curve + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array); + cmd.set_preselection( + selected + .iter() + .map(|(handle, entity)| (*handle, (*entity).clone())) + .collect(), + anchor, + direction, + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index d31ec464c..bdf4d6c62 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,7 +7,7 @@ pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; mod document; -mod expr_eval; +pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; mod history; diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 1de87371e..43f174279 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -40,6 +40,33 @@ impl super::OpenCADStudio { handle } + /// Add an open sheet body as a persistent Surface entity and register its + /// exact B-rep for shaded and wireframe display. + pub(super) fn add_surface_model( + &mut self, + mut entity: EntityType, + surface: Body, + ) -> Handle { + let i = self.active_tab; + let EntityType::Surface(inner) = &mut entity else { + return Handle::NULL; + }; + inner.common.plotstyle_flags = 2; + inner.wires = solid_model::edge_wires(&surface); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&surface) + else { + self.command_line + .push_error(crate::t!("The surface could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + inner.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + let Some(handle) = self.commit_entity_handle(entity) else { + return Handle::NULL; + }; + self.tabs[i].scene.register_solid_model(handle, surface); + handle + } + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; let mut handles: Vec = self.tabs[i] diff --git a/src/command.rs b/src/command.rs index 536152687..c9899e9d0 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1129,6 +1129,19 @@ impl CadCommand for TCountCommand { } // ── Result token ────────────────────────────────────────────────────────── +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExtrudeMode { + Solid, + Surface, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ExtrudeExtent { + Height(f64), + Direction(DVec3), + Path(Handle), +} + /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { @@ -1414,10 +1427,12 @@ pub enum CmdResult { /// Translation vector applied once to every selected point. delta: DVec3, }, - /// Extrude the profile entity `handle` along its plane normal. - ExtrudeEntity { - handle: Handle, - height: f64, + /// Extrude one or more profiles with the requested construction mode. + ExtrudeEntities { + handles: Vec, + extent: ExtrudeExtent, + mode: ExtrudeMode, + taper_angle: f64, color: [f32; 4], }, /// Pull a closed profile or a planar solid face by a signed distance. diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index db2898da0..da478712e 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1,9 +1,14 @@ // Profile-based kernel solid commands stored as exact ACIS data. -use acadrust::{entities::Solid3D, EntityType}; +use std::sync::{Mutex, OnceLock}; + +use acadrust::{entities::Solid3D, EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, +}; +use crate::scene::WireModel; use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── @@ -11,32 +16,173 @@ use crate::t; pub struct ExtrudeCommand { command_name: &'static str, step: ExtrudeStep, - pub target_handle: acadrust::Handle, + handles: Vec, + preview_profiles: Vec<(Handle, EntityType)>, + injected_profile: Option, anchor: DVec3, - direction: Option, + profile_direction: Option, + direction_start: Option, + mode: ExtrudeMode, + taper_angle: f64, + last_height: Option, + taper_return: ExtrudeStep, color: [f32; 4], } -#[derive(PartialEq)] +#[derive(Clone, Copy)] +struct ExtrudeDefaults { + mode: ExtrudeMode, + height: Option, + taper_angle: f64, +} + +fn extrude_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| { + Mutex::new(ExtrudeDefaults { + mode: ExtrudeMode::Solid, + height: None, + taper_angle: 0.0, + }) + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] enum ExtrudeStep { Pick, + Mode, Height, + DirectionStart, + DirectionEnd, + Path, + Taper, + Expression, + TaperExpression, } impl ExtrudeCommand { pub fn new_named(name: &str, color: [f32; 4]) -> Self { + let defaults = *extrude_defaults().lock().unwrap_or_else(|error| error.into_inner()); Self { command_name: match name { "THICKEN" => "THICKEN", _ => "EXTRUDE", }, step: ExtrudeStep::Pick, - target_handle: acadrust::Handle::NULL, + handles: Vec::new(), + preview_profiles: Vec::new(), + injected_profile: None, anchor: DVec3::ZERO, - direction: None, + profile_direction: None, + direction_start: None, + mode: defaults.mode, + taper_angle: defaults.taper_angle, + last_height: defaults.height, + taper_return: ExtrudeStep::Height, color, } } + + pub fn set_preselection( + &mut self, + profiles: Vec<(Handle, EntityType)>, + anchor: DVec3, + direction: Option, + ) { + self.handles = profiles.iter().map(|(handle, _)| *handle).collect(); + self.preview_profiles = profiles; + self.anchor = anchor; + self.profile_direction = direction.and_then(DVec3::try_normalize); + if !self.handles.is_empty() { + self.step = ExtrudeStep::Height; + } + } + + fn finish(&self, extent: ExtrudeExtent) -> CmdResult { + if let Some(height) = match extent { + ExtrudeExtent::Height(height) => Some(height), + ExtrudeExtent::Direction(direction) => Some(direction.length()), + ExtrudeExtent::Path(_) => None, + } { + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .height = Some(height); + } + CmdResult::ExtrudeEntities { + handles: self.handles.clone(), + extent, + mode: self.mode, + taper_angle: self.taper_angle, + color: self.color, + } + } + + fn preview_wires(&self, cursor: DVec3) -> Vec { + if self.step != ExtrudeStep::Height { + return Vec::new(); + } + let Some(axis) = self.profile_direction else { + return Vec::new(); + }; + let height = (cursor - self.anchor).dot(axis); + if !height.is_finite() || height.abs() <= 1e-6 { + return Vec::new(); + } + + self.preview_profiles + .iter() + .flat_map(|(_, entity)| { + let Some((profile, closed)) = + crate::scene::model::sweep_model::extrusion_profile_of(entity) + else { + return Vec::new(); + }; + let Some(normal) = profile.plane.normal() else { + return Vec::new(); + }; + let direction = [normal[0] * height, normal[1] * height, normal[2] * height]; + let creates_surface = self.mode == ExtrudeMode::Surface || !closed; + let body = if creates_surface { + crate::scene::model::sweep_model::extruded_surface( + entity, + direction, + self.taper_angle, + ) + } else { + crate::scene::model::sweep_model::extruded_direction( + entity, + direction, + self.taper_angle, + ) + }; + body.map(|body| preview_body_wires(&body, self.color)) + .unwrap_or_default() + }) + .collect() + } +} + +fn preview_body_wires(body: &cadkernel::brep::Body, color: [f32; 4]) -> Vec { + let mesh = cadkernel::brep::mesh::tessellate( + body, + cadkernel::brep::mesh::TessellationTolerance::new( + cadkernel::tessellation::DEFAULT_ANGLE, + 1e-9, + ), + ); + mesh.edges + .into_iter() + .filter(|edge| edge.positions.len() >= 2) + .map(|edge| { + WireModel::solid_f64( + "EXTRUDE-PREVIEW".to_owned(), + edge.positions, + color, + false, + ) + }) + .collect() } impl CadCommand for ExtrudeCommand { @@ -45,63 +191,249 @@ impl CadCommand for ExtrudeCommand { } fn prompt(&self) -> String { match self.step { - ExtrudeStep::Pick => t!("EXTRUDE Select closed profile (Circle, LwPolyline…):") - .into_owned(), - ExtrudeStep::Height => t!("EXTRUDE Height:").into_owned(), + ExtrudeStep::Pick => t!("EXTRUDE Select objects to extrude or [Mode] (Enter to finish):").into_owned(), + ExtrudeStep::Mode => format!( + "{} <{}>:", + t!("EXTRUDE Creation mode [Solid/Surface]"), + if self.mode == ExtrudeMode::Solid { "Solid" } else { "Surface" } + ), + ExtrudeStep::Height => { + let base = t!("EXTRUDE Specify height or [Direction/Path/Taper angle/Expression]"); + self.last_height.map_or_else( + || format!("{base}:"), + |height| format!("{base} <{}>:", crate::entities::common::format_length(height)), + ) + } + ExtrudeStep::DirectionStart => t!("EXTRUDE Start point of direction:").into_owned(), + ExtrudeStep::DirectionEnd => t!("EXTRUDE End point of direction:").into_owned(), + ExtrudeStep::Path => t!("EXTRUDE Select extrusion path or [Taper angle]:").into_owned(), + ExtrudeStep::Taper => format!( + "{} <{}>:", + t!("EXTRUDE Specify taper angle or [Expression]"), + crate::entities::common::format_angle(self.taper_angle) + ), + ExtrudeStep::Expression => t!("EXTRUDE Enter height expression:").into_owned(), + ExtrudeStep::TaperExpression => t!("EXTRUDE Enter taper angle expression:").into_owned(), + } + } + fn options(&self) -> Vec { + match self.step { + ExtrudeStep::Pick => vec![CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + ExtrudeStep::Mode => vec![ + CmdOption::new("Solid", "SOLID"), + CmdOption::new("Surface", "SURFACE"), + ], + ExtrudeStep::Height => vec![ + CmdOption::new("Direction", "DIRECTION"), + CmdOption::new("Path", "PATH"), + CmdOption::new("Taper angle", "TAPER"), + CmdOption::new("Expression", "EXPRESSION"), + ], + ExtrudeStep::Path => vec![CmdOption::new("Taper angle", "TAPER")], + ExtrudeStep::Taper => vec![CmdOption::new("Expression", "EXPRESSION")], + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.step == ExtrudeStep::Pick + self.step == ExtrudeStep::Path } fn entity_pick_uses_surface_point(&self) -> bool { true } fn set_entity_pick_direction(&mut self, direction: Option) { - self.direction = direction.and_then(DVec3::try_normalize); + if self.step == ExtrudeStep::Pick { + self.profile_direction = direction.and_then(DVec3::try_normalize); + } } - fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - self.target_handle = handle; - self.anchor = point; - self.step = ExtrudeStep::Height; - CmdResult::NeedPoint + match self.step { + ExtrudeStep::Pick => { + if !self.handles.contains(&handle) { + self.handles.push(handle); + self.anchor = point; + if let Some(entity) = self.injected_profile.take() { + self.preview_profiles.push((handle, entity)); + } + } else { + self.injected_profile = None; + } + CmdResult::NeedPoint + } + ExtrudeStep::Path if !self.handles.contains(&handle) => { + self.finish(ExtrudeExtent::Path(handle)) + } + _ => CmdResult::NeedPoint, + } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.step == ExtrudeStep::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; + match self.step { + ExtrudeStep::Height => { + let height = self + .profile_direction + .map(|direction| (pt - self.anchor).dot(direction)) + .unwrap_or_else(|| pt.distance(self.anchor)); + if height.is_finite() && height.abs() > 1e-6 { + self.finish(ExtrudeExtent::Height(height)) + } else { + CmdResult::NeedPoint + } } - return CmdResult::ExtrudeEntity { - handle: self.target_handle, - height, - color: self.color, - }; + ExtrudeStep::DirectionStart => { + self.direction_start = Some(pt); + self.step = ExtrudeStep::DirectionEnd; + CmdResult::NeedPoint + } + ExtrudeStep::DirectionEnd => { + let direction = pt - self.direction_start.unwrap_or(pt); + if direction.is_finite() && direction.length_squared() > 1e-12 { + self.finish(ExtrudeExtent::Direction(direction)) + } else { + CmdResult::NeedPoint + } + } + _ => CmdResult::NeedPoint, } - CmdResult::NeedPoint } fn wants_text_input(&self) -> bool { - self.step == ExtrudeStep::Height + matches!( + self.step, + ExtrudeStep::Pick + | ExtrudeStep::Mode + | ExtrudeStep::Height + | ExtrudeStep::Path + | ExtrudeStep::Taper + | ExtrudeStep::Expression + | ExtrudeStep::TaperExpression + ) + } + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, ExtrudeStep::Height | ExtrudeStep::Path) } fn on_text_input(&mut self, text: &str) -> Option { - crate::entities::common::parse_typed_length(text) - .filter(|&h| h.abs() > 1e-6) - .map(|h| CmdResult::ExtrudeEntity { - handle: self.target_handle, - height: h, - color: self.color, - }) + let value = text.trim(); + // A bare Enter finalizes the current source selection through + // `on_enter`. Treating the empty string as an option prefix makes it + // match every keyword and silently advances to the wrong branch. + if value.is_empty() { + return None; + } + match self.step { + ExtrudeStep::Pick if "MODE".starts_with(&value.to_ascii_uppercase()) => { + self.step = ExtrudeStep::Mode; + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Mode => { + let upper = value.to_ascii_uppercase(); + if "SOLID".starts_with(&upper) { + self.mode = ExtrudeMode::Solid; + } else if "SURFACE".starts_with(&upper) { + self.mode = ExtrudeMode::Surface; + } else { + return Some(CmdResult::NeedPoint); + } + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .mode = self.mode; + self.step = ExtrudeStep::Pick; + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Height => { + let upper = value.to_ascii_uppercase(); + if "DIRECTION".starts_with(&upper) { + self.step = ExtrudeStep::DirectionStart; + return Some(CmdResult::NeedPoint); + } + if "PATH".starts_with(&upper) { + self.step = ExtrudeStep::Path; + return Some(CmdResult::NeedPoint); + } + if "TAPER".starts_with(&upper) || "TAPER ANGLE".starts_with(&upper) { + self.taper_return = ExtrudeStep::Height; + self.step = ExtrudeStep::Taper; + return Some(CmdResult::NeedPoint); + } + if "EXPRESSION".starts_with(&upper) { + self.step = ExtrudeStep::Expression; + return Some(CmdResult::NeedPoint); + } + crate::entities::common::parse_typed_length(value) + .filter(|height| height.is_finite() && height.abs() > 1e-6) + .map(|height| self.finish(ExtrudeExtent::Height(height))) + } + ExtrudeStep::Path => { + let upper = value.to_ascii_uppercase(); + if "TAPER".starts_with(&upper) || "TAPER ANGLE".starts_with(&upper) { + self.taper_return = ExtrudeStep::Path; + self.step = ExtrudeStep::Taper; + } + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Taper => { + if "EXPRESSION".starts_with(&value.to_ascii_uppercase()) { + self.step = ExtrudeStep::TaperExpression; + return Some(CmdResult::NeedPoint); + } + let angle = crate::entities::common::parse_angle(value)?; + if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Some(CmdResult::NeedPoint); + } + self.taper_angle = angle; + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .taper_angle = angle; + self.step = self.taper_return; + Some(CmdResult::NeedPoint) + } + ExtrudeStep::Expression => crate::app::expr_eval::eval_number(value) + .filter(|height| height.is_finite() && height.abs() > 1e-6) + .map(|height| self.finish(ExtrudeExtent::Height(height))), + ExtrudeStep::TaperExpression => { + let angle = crate::app::expr_eval::eval_number(value) + .and_then(|value| crate::entities::common::parse_angle(&value.to_string()))?; + if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Some(CmdResult::NeedPoint); + } + self.taper_angle = angle; + extrude_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .taper_angle = angle; + self.step = self.taper_return; + Some(CmdResult::NeedPoint) + } + _ => None, + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + ExtrudeStep::Pick if !self.handles.is_empty() => { + self.step = ExtrudeStep::Height; + CmdResult::NeedPoint + } + ExtrudeStep::Mode => { + self.step = ExtrudeStep::Pick; + CmdResult::NeedPoint + } + ExtrudeStep::Height => self + .last_height + .filter(|height| height.is_finite() && height.abs() > 1e-6) + .map(|height| self.finish(ExtrudeExtent::Height(height))) + .unwrap_or(CmdResult::Cancel), + ExtrudeStep::Taper => { + self.step = self.taper_return; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } } fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { - (self.step == ExtrudeStep::Height).then_some((self.anchor, self.direction?)) + (self.step == ExtrudeStep::Height) + .then_some((self.anchor, self.profile_direction?)) } fn dyn_spec(&self) -> Option { (self.step == ExtrudeStep::Height).then_some(crate::command::DynSpec { @@ -114,7 +446,46 @@ impl CadCommand for ExtrudeCommand { }) } fn dyn_live_value(&self, cursor: DVec3) -> Option { - Some((cursor - self.anchor).dot(self.direction?)) + Some((cursor - self.anchor).dot(self.profile_direction?)) + } + fn is_selection_gathering(&self) -> bool { + self.step == ExtrudeStep::Pick + } + fn selection_forces_add(&self) -> bool { + self.step == ExtrudeStep::Pick + } + fn inject_selection_entities(&mut self, entities: Vec) { + if self.step != ExtrudeStep::Pick { + return; + } + self.handles = entities.iter().map(|entry| entry.handle).collect(); + self.preview_profiles = entities + .iter() + .map(|entry| (entry.handle, entry.entity.clone())) + .collect(); + if let Some(curve) = entities + .iter() + .find_map(|entry| crate::entities::curve::entity_curve(&entry.entity)) + { + self.anchor = DVec3::from_array(curve.plane.origin); + self.profile_direction = curve.plane.normal().map(DVec3::from_array); + } else { + self.profile_direction = None; + } + } + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + fn inject_before_entity_pick(&self) -> bool { + self.step == ExtrudeStep::Pick + } + fn inject_picked_entity(&mut self, entity: EntityType) { + if self.step == ExtrudeStep::Pick { + self.injected_profile = Some(entity); + } + } + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + self.preview_wires(cursor) } } @@ -479,6 +850,24 @@ pub fn empty_solid3d() -> EntityType { EntityType::Solid3D(Solid3D::new()) } +pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType { + use acadrust::entities::{Surface, SurfaceData, SurfaceKind}; + use acadrust::types::Vector3; + + let mut surface = Surface::new(SurfaceKind::Extruded); + if let SurfaceData::Extruded { + options, + sweep_vector, + .. + } = &mut surface.surface_data + { + options.draft_angle = taper_angle; + options.is_solid = false; + *sweep_vector = Vector3::new(direction.x, direction.y, direction.z); + } + EntityType::Surface(surface) +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE", "THICKEN", "PRESSPULL"] diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 76fdad524..ebfa32df8 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1,5 +1,5 @@ use acadrust::entities::acis::{SabReader, SatBody, SatDocument}; -use acadrust::entities::{Body, Region, Solid3D}; +use acadrust::entities::{Body, Region, Solid3D, Surface}; use cadkernel::brep::{self, Body as KernelBody}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -156,10 +156,18 @@ fn parse_acis( } pub fn kernel_body(solid: &Solid3D) -> Option { + kernel_acis_body(&solid.acis_data) +} + +pub fn kernel_surface_body(surface: &Surface) -> Option { + kernel_acis_body(&surface.acis_data) +} + +fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { let sat = parse_acis( - || solid.parse_sat(), - solid.acis_data.is_binary, - &solid.acis_data.sab_data, + || acis.parse(), + acis.is_binary, + &acis.sab_data, )?; let (mut bodies, loss) = cadkernel::acis::lift(&sat); if !loss.is_empty() || bodies.len() != 1 { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 85f25954e..1fe0317b4 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -602,6 +602,9 @@ impl Scene { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } + Some(EntityType::Surface(surface)) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } _ => None, })?; Some((handle, body)) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 13f5bf6c2..e69d548a1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2725,12 +2725,21 @@ impl Scene { if let Some((mut set, wires, center)) = crate::scene::model::solid_model::display_from_solid(&solid, color) { - if let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) { - entity.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - entity.wires = wires; - entity.silhouettes.clear(); + if let Some(entity) = self.document.get_entity_mut(handle) { + let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); + match entity { + EntityType::Solid3D(entity) => { + entity.point_of_reference = reference; + entity.wires = wires; + entity.silhouettes.clear(); + } + EntityType::Surface(entity) => { + entity.point_of_reference = reference; + entity.wires = wires; + entity.silhouettes.clear(); + } + _ => {} + } } let name = handle.value().to_string(); for mesh in &mut set.lods { diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 3542bf6dd..30b24d10b 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,12 +1,14 @@ -use acadrust::entities::Solid3D; +use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; +use acadrust::EntityType; use cadkernel::brep::Body; use crate::command::EntityTransform; +use crate::entities::traits::EntityTypeOps; use crate::scene::model::object::{ GripApply, GripDef, GripShape, PropSection, PropValue, Property, }; @@ -23,6 +25,8 @@ pub const GRIP_MAJOR_RADIUS: usize = 10_008; pub const GRIP_MINOR_RADIUS: usize = 10_009; pub const GRIP_TOP_RADIUS: usize = 10_010; pub const GRIP_POSITION: usize = 10_011; +pub const GRIP_DRAFT: usize = 10_012; +pub const GRIP_PROFILE_FIRST: usize = 10_200; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -57,6 +61,8 @@ pub const PROP_BANK: &str = "solid_history_bank"; pub const PROP_TWIST_ALONG_PATH: &str = "solid_history_twist_along_path"; pub const PROP_SCALE_ALONG_PATH: &str = "solid_history_scale_along_path"; pub const PROP_SWEEP_LENGTH: &str = "solid_history_sweep_length"; +pub const PROP_EXTRUSION_HEIGHT: &str = "solid_history_extrusion_height"; +pub const PROP_TAPER_ANGLE: &str = "solid_history_taper_angle"; pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; @@ -110,6 +116,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Torus(_) | SolidHistoryOperation::Pyramid(_) | SolidHistoryOperation::Sweep(_) + | SolidHistoryOperation::Extrusion(_) ) ) } @@ -125,7 +132,7 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } SolidHistoryOperation::Cone(value) => world_point(value.base.transform, [0.0; 3]), SolidHistoryOperation::Cylinder(value) => world_point(value.base.transform, [0.0; 3]), - SolidHistoryOperation::Sweep(value) => world_point( + SolidHistoryOperation::Sweep(value) | SolidHistoryOperation::Extrusion(value) => world_point( value.base.transform, [ value.reference_point.x, @@ -139,6 +146,100 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option } } +fn extrusion_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistorySweep, +) -> Vec { + let position = world_point( + value.base.transform, + [ + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + ], + ) + .unwrap_or(glam::DVec3::ZERO); + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + let path_length = sweep_path_length(value); + let height_value = path_length.unwrap_or(value.end_draft_distance); + let height = if value.path_entity.is_some() { + PropValue::ReadOnly(crate::entities::common::format_length(height_value)) + } else { + PropValue::EditText(crate::entities::common::format_length(height_value)) + }; + let taper = if value.path_entity.is_some() { + PropValue::ReadOnly(crate::entities::common::format_angle(value.draft_angle)) + } else { + PropValue::EditText(crate::entities::common::format_angle(value.draft_angle)) + }; + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Extrusion").into_owned()), + }, + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + Property { + label: t!("Height").into_owned(), + field: PROP_EXTRUSION_HEIGHT, + value: height, + }, + Property { + label: t!("Taper angle").into_owned(), + field: PROP_TAPER_ANGLE, + value: taper, + }, + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: if show_history { "Yes" } else { "No" }.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string()) + }, + }, + ], + }, + ] +} + fn sweep_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -226,7 +327,30 @@ fn sweep_path_length(value: &SolidHistorySweep) -> Option { use acadrust::entities::EmbeddedEntity; use acadrust::EntityType; - let entity = match value.path_entity.as_ref()? { + let path_entity = value.path_entity.as_ref()?; + if let EmbeddedEntity::Spline(path) = path_entity { + if path.degree == 1 && path.control_points.len() >= 2 && path.weights.is_empty() { + let transform = glam::DMat4::from_cols_array(&value.path_entity_transform); + if !transform.is_finite() || transform.determinant().abs() <= 1e-12 { + return None; + } + return Some( + path.control_points + .windows(2) + .map(|pair| { + let start = transform.transform_point3(glam::DVec3::new( + pair[0].x, pair[0].y, pair[0].z, + )); + let end = transform.transform_point3(glam::DVec3::new( + pair[1].x, pair[1].y, pair[1].z, + )); + end.distance(start) + }) + .sum(), + ); + } + } + let entity = match path_entity { EmbeddedEntity::Line(value) => EntityType::Line(value.clone()), EmbeddedEntity::Arc(value) => EntityType::Arc(value.clone()), EmbeddedEntity::Circle(value) => EntityType::Circle(value.clone()), @@ -889,6 +1013,9 @@ pub fn primitive_properties( SolidHistoryOperation::Pyramid(value) => pyramid_properties(document, handle, value), SolidHistoryOperation::Torus(value) => torus_properties(document, handle, value), SolidHistoryOperation::Sweep(value) => sweep_properties(document, handle, value), + SolidHistoryOperation::Extrusion(value) => { + extrusion_properties(document, handle, value) + } _ => Vec::new(), } } @@ -919,6 +1046,8 @@ pub fn is_primitive_property(field: &str) -> bool { | PROP_PROFILE_ROTATION | PROP_TWIST_ALONG_PATH | PROP_SCALE_ALONG_PATH + | PROP_EXTRUSION_HEIGHT + | PROP_TAPER_ANGLE | PROP_PYRAMID_TYPE ) } @@ -933,6 +1062,81 @@ pub fn is_specialized_property(field: &str) -> bool { || is_history_choice(field) } +fn apply_extrusion_geometry_property( + value: &mut SolidHistorySweep, + field: &str, + text: &str, +) -> Option { + if value.path_entity.is_some() + && matches!(field, PROP_EXTRUSION_HEIGHT | PROP_TAPER_ANGLE) + { + return Some(false); + } + match field { + PROP_POSITION_X | PROP_POSITION_Y | PROP_POSITION_Z => { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + _ => 2, + }; + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let current = matrix(value.base.transform)?; + let reference = current.transform_point3(glam::DVec3::new( + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + )); + if !reference.is_finite() || (target - reference[axis]).abs() <= 1e-12 { + return Some(false); + } + let mut delta = glam::DVec3::ZERO; + delta[axis] = target - reference[axis]; + let updated = glam::DMat4::from_translation(delta) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) + } + PROP_EXTRUSION_HEIGHT => { + let height = crate::entities::common::parse_length(text)?; + if !height.is_finite() || height <= 1e-9 { + return Some(false); + } + let direction = glam::DVec3::new( + value.direction.x, + value.direction.y, + value.direction.z, + ); + let Some(unit) = direction.try_normalize() else { + return Some(false); + }; + let updated = unit * height; + if (updated - direction).length_squared() <= 1e-24 { + return Some(false); + } + value.direction = acadrust::types::Vector3::new(updated.x, updated.y, updated.z); + value.end_draft_distance = height; + Some(true) + } + PROP_TAPER_ANGLE => { + let angle = crate::entities::common::parse_angle(text)?; + if !angle.is_finite() + || angle.abs() >= std::f64::consts::FRAC_PI_2 + || (angle - value.draft_angle).abs() <= 1e-12 + { + return Some(false); + } + value.draft_angle = angle; + Some(true) + } + _ => None, + } +} + pub fn apply_history_choice( document: &mut acadrust::CadDocument, handle: acadrust::Handle, @@ -1362,6 +1566,13 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Extrusion(extrusion_value) = operation { + if let Some(applied) = + apply_extrusion_geometry_property(extrusion_value, field, value) + { + return applied; + } + } if let SolidHistoryOperation::Torus(torus_value) = operation { if let Some(applied) = apply_torus_position_property(torus_value, field, value) { return applied; @@ -1641,6 +1852,214 @@ fn local_point(transform: [f64; 16], point: glam::DVec3) -> Option Some(matrix(transform)?.inverse().transform_point3(point)) } +fn embedded_entity(value: &EmbeddedEntity) -> Option { + Some(match value { + EmbeddedEntity::Point(value) => EntityType::Point(value.clone()), + EmbeddedEntity::Line(value) => EntityType::Line(value.clone()), + EmbeddedEntity::Arc(value) => EntityType::Arc(value.clone()), + EmbeddedEntity::Circle(value) => EntityType::Circle(value.clone()), + EmbeddedEntity::Ellipse(value) => EntityType::Ellipse(value.clone()), + EmbeddedEntity::Spline(value) => EntityType::Spline(value.clone()), + EmbeddedEntity::LwPolyline(value) => EntityType::LwPolyline(value.clone()), + EmbeddedEntity::Ray(value) => EntityType::Ray(value.clone()), + EmbeddedEntity::XLine(value) => EntityType::XLine(value.clone()), + EmbeddedEntity::Unknown { .. } => return None, + }) +} + +fn into_embedded_entity(value: EntityType) -> Option { + Some(match value { + EntityType::Point(value) => EmbeddedEntity::Point(value), + EntityType::Line(value) => EmbeddedEntity::Line(value), + EntityType::Arc(value) => EmbeddedEntity::Arc(value), + EntityType::Circle(value) => EmbeddedEntity::Circle(value), + EntityType::Ellipse(value) => EmbeddedEntity::Ellipse(value), + EntityType::Spline(value) => EmbeddedEntity::Spline(value), + EntityType::LwPolyline(value) => EmbeddedEntity::LwPolyline(value), + EntityType::Ray(value) => EmbeddedEntity::Ray(value), + EntityType::XLine(value) => EmbeddedEntity::XLine(value), + _ => return None, + }) +} + +fn profile_matrix(value: &SolidHistorySweep) -> Option { + Some(matrix(value.base.transform)? * matrix(value.sweep_entity_transform)?) +} + +fn extrusion_profile_grips(value: &SolidHistorySweep) -> (Vec, Option) { + let Some(profile) = value.sweep_entity.as_ref().and_then(embedded_entity) else { + return (Vec::new(), None); + }; + let Some(transform) = profile_matrix(value) else { + return (Vec::new(), None); + }; + let source = profile.grips(); + if source.is_empty() { + return (Vec::new(), None); + } + + let mut center = glam::DVec3::ZERO; + let mut count = 0.0; + let grips = source + .into_iter() + .enumerate() + .filter_map(|(index, grip)| { + let world = transform.transform_point3(grip.world); + if !world.is_finite() { + return None; + } + center += world; + count += 1.0; + let transform_vector = |vector: glam::DVec3| { + let vector = transform.transform_vector3(vector); + (vector.length_squared() > 1e-12).then(|| vector.normalize()) + }; + Some(GripDef { + id: GRIP_PROFILE_FIRST + index, + world, + is_midpoint: grip.is_midpoint, + shape: grip.shape, + dir: grip.dir.and_then(transform_vector), + axis: grip.axis.and_then(transform_vector), + }) + }) + .collect::>(); + let center = (count > 0.0).then_some(center / count); + (grips, center) +} + +fn apply_extrusion_profile_grip( + value: &mut SolidHistorySweep, + index: usize, + apply: GripApply, +) -> bool { + let Some(mut profile) = value.sweep_entity.as_ref().and_then(embedded_entity) else { + return false; + }; + let Some(source_grip) = profile.grips().get(index).cloned() else { + return false; + }; + let Some(transform) = profile_matrix(value) else { + return false; + }; + let inverse = transform.inverse(); + if !inverse.is_finite() { + return false; + } + let local_apply = match apply { + GripApply::Absolute(world) => { + let local = inverse.transform_point3(world); + if !local.is_finite() { + return false; + } + GripApply::Absolute(local) + } + GripApply::Translate(world_delta) => { + let local_delta = inverse.transform_vector3(world_delta); + if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 { + return false; + } + GripApply::Translate(local_delta) + } + }; + profile.apply_grip(source_grip.id, local_apply); + let Some(profile) = into_embedded_entity(profile) else { + return false; + }; + value.sweep_entity = Some(profile); + true +} + +fn extrusion_draft_grip( + value: &SolidHistorySweep, +) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, f64, f64)> { + if value.path_entity.is_some() { + return None; + } + let (profile_grips, center) = extrusion_profile_grips(value); + let center = center?; + let transform = profile_matrix(value)?; + let inverse = transform.inverse(); + if !inverse.is_finite() { + return None; + } + let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?; + let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?; + let normal = transform + .transform_vector3(glam::DVec3::from_array(normal)) + .try_normalize()?; + let center_local = inverse.transform_point3(center); + let anchor = profile_grips + .into_iter() + .filter_map(|grip| { + let local = inverse.transform_point3(grip.world); + let radius = (grip.world - center).length_squared(); + (local.is_finite() && radius > 1e-12).then_some((grip.world, local)) + }) + .min_by(|(_, a), (_, b)| { + a.x.total_cmp(&b.x) + .then_with(|| { + (a.y - center_local.y) + .abs() + .total_cmp(&(b.y - center_local.y).abs()) + }) + .then_with(|| { + (a.z - center_local.z) + .abs() + .total_cmp(&(b.z - center_local.z).abs()) + }) + })? + .0; + let radial = anchor - center; + let radial_length = radial.length(); + if !radial_length.is_finite() || radial_length <= 1e-6 { + return None; + } + let radial = radial / radial_length; + let base = matrix(value.base.transform)?; + let direction = base.transform_vector3(glam::DVec3::new( + value.direction.x, + value.direction.y, + value.direction.z, + )); + let height = direction.dot(normal).abs(); + if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { + return None; + } + let draft_offset = -height * value.draft_angle.tan(); + if !draft_offset.is_finite() { + return None; + } + let base_top = anchor + direction; + let world = base_top + radial * draft_offset; + world + .is_finite() + .then_some((world, base_top, radial, radial_length, height)) +} + +fn apply_extrusion_draft_grip(value: &mut SolidHistorySweep, apply: GripApply) -> bool { + let GripApply::Absolute(world) = apply else { + return false; + }; + let Some((_, base_top, radial, profile_radius, height)) = extrusion_draft_grip(value) else { + return false; + }; + let top_offset = (world - base_top).dot(radial); + if !top_offset.is_finite() { + return false; + } + let limit = 89.0_f64.to_radians(); + let inward_limit = ((profile_radius - 1e-6).max(1e-6) / height) + .atan() + .min(limit); + let angle = (-top_offset).atan2(height).clamp(-limit, inward_limit); + if !angle.is_finite() || (angle - value.draft_angle).abs() <= 1e-12 { + return false; + } + value.draft_angle = angle; + true +} + fn grip( id: usize, world: glam::DVec3, @@ -1873,6 +2292,32 @@ pub fn primitive_grips( GripShape::Square, None, ), + SolidHistoryOperation::Extrusion(value) => { + let (profile_grips, profile_center) = extrusion_profile_grips(value); + grips.extend(profile_grips); + let direction = [value.direction.x, value.direction.y, value.direction.z]; + if value.path_entity.is_none() { + if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { + let direction = base.transform_vector3(glam::DVec3::from_array(direction)); + if direction.length_squared() > 1e-12 { + grips.push(grip( + GRIP_HEIGHT, + center + direction, + GripShape::Triangle, + Some(direction.normalize()), + )); + } + } + if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) { + grips.push(grip( + GRIP_DRAFT, + world, + GripShape::Triangle, + Some(radial), + )); + } + } + } _ => {} } grips @@ -1883,6 +2328,14 @@ pub fn apply_primitive_grip( grip_id: usize, apply: GripApply, ) -> bool { + if let SolidHistoryOperation::Extrusion(value) = operation { + if let Some(index) = grip_id.checked_sub(GRIP_PROFILE_FIRST) { + return apply_extrusion_profile_grip(value, index, apply); + } + if grip_id == GRIP_DRAFT { + return apply_extrusion_draft_grip(value, apply); + } + } let GripApply::Absolute(world) = apply else { return false; }; @@ -2032,6 +2485,41 @@ pub fn apply_primitive_grip( value.base.transform = (glam::DMat4::from_translation(delta) * current).to_cols_array(); } + SolidHistoryOperation::Extrusion(value) => match grip_id { + GRIP_HEIGHT => { + if value.path_entity.is_some() { + return false; + } + let (_, Some(profile_center)) = extrusion_profile_grips(value) else { + return false; + }; + let Some(current) = matrix(value.base.transform) else { + return false; + }; + let direction = current + .inverse() + .transform_vector3(world - profile_center); + let height = direction.length(); + if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { + return false; + } + let current = glam::DVec3::new( + value.direction.x, + value.direction.y, + value.direction.z, + ); + if (direction - current).length_squared() <= 1e-24 { + return false; + } + value.direction = acadrust::types::Vector3::new( + direction.x, + direction.y, + direction.z, + ); + value.end_draft_distance = height; + } + _ => return false, + }, _ => return false, } true diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 247a8e5d1..cc2738e8a 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -3,7 +3,7 @@ use cadkernel::brep::{self, Body}; use cadkernel::geom2d::{Arc, Curve, EllipseArc, Line}; use cadkernel::space::{PlanarCurve, Plane, Vec3}; -use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex}; +use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex, Spline}; use acadrust::objects::{ SolidHistoryNodeBase, SolidHistoryOperation, SolidHistorySweep, }; @@ -28,30 +28,185 @@ pub struct Profile { /// is handed over as the arcs it is made of rather than as one curve with /// nowhere for the sweep to start. pub fn profile_of(entity: &EntityType) -> Option { - let planar: PlanarCurve = entity_curve(entity)?; + let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?; if !planar.curve.is_closed() { return None; } - let pieces = match &planar.curve { - Curve::Polyline(_) => planar.curve.segments(), - Curve::Circle(circle) => quarters(circle.centre, circle.radius), - Curve::Arc(arc) => split_arc(*arc), - Curve::Ellipse(arc) => split_ellipse(*arc), - Curve::Nurbs(curve) => (0..4) - .map(|part| { - curve - .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) - .map(Curve::Nurbs) - }) - .collect::>>()?, - _ => return None, - }; + let pieces = profile_pieces(&planar.curve, true)?; (pieces.len() >= 3).then_some(Profile { plane: planar.plane, pieces, }) } +/// An entity-level planar curve suitable for either a solid profile or an +/// open surface profile. +pub fn extrusion_profile_of(entity: &EntityType) -> Option<(Profile, bool)> { + let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?; + let closed = planar.curve.is_closed(); + let pieces = profile_pieces(&planar.curve, closed)?; + (!pieces.is_empty()).then_some(( + Profile { + plane: planar.plane, + pieces, + }, + closed, + )) +} + +fn profile_pieces(curve: &Curve, closed: bool) -> Option> { + let pieces = match curve { + Curve::Polyline(_) => curve.segments(), + Curve::Circle(circle) => quarters(circle.centre, circle.radius), + Curve::Arc(arc) if closed => split_arc(*arc), + Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Ellipse(arc) if closed => split_ellipse(*arc), + Curve::Ellipse(arc) => vec![Curve::Ellipse(*arc)], + Curve::Line(line) => vec![Curve::Line(*line)], + Curve::Nurbs(nurbs) if closed => (0..4) + .map(|part| { + nurbs + .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) + .map(Curve::Nurbs) + }) + .collect::>>()?, + Curve::Nurbs(nurbs) => vec![Curve::Nurbs(nurbs.clone())], + _ => return None, + }; + Some(pieces) +} + +fn planar_polygon_entity(entity: &EntityType) -> Option { + let (points, closed) = match entity { + EntityType::Polyline3D(value) => ( + value + .vertices + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect::>(), + value.is_closed(), + ), + EntityType::Face3D(value) => ( + value + .corners() + .into_iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(), + true, + ), + EntityType::Solid(value) => ( + value + .boundary_corners() + .into_iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(), + true, + ), + EntityType::Region(value) => ( + value + .wires + .first()? + .points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(), + true, + ), + _ => return None, + }; + let mut points = points; + if closed && points.len() > 2 && points.first() == points.last() { + points.pop(); + } + if points.len() < if closed { 3 } else { 2 } { + return None; + } + let origin = Vec3::from(points[0]); + let first = Vec3::from(points[1]) - origin; + let normal = points[2..] + .iter() + .find_map(|point| first.cross(Vec3::from(*point) - origin).normalize()) + .or_else(|| { + (!closed).then(|| first.cross(Vec3::Z).normalize().unwrap_or(Vec3::Y)) + })?; + let plane = Plane::orthonormal(points[0], first.to_array(), normal.to_array())?; + let scale = points + .iter() + .flatten() + .map(|value| value.abs()) + .fold(1.0_f64, f64::max); + if points + .iter() + .any(|point| !plane.contains(*point, scale * 1e-9)) + { + return None; + } + let vertices = points + .iter() + .map(|point| { + plane.project(*point).map(|position| cadkernel::geom2d::PolylineVertex { + position, + bulge: 0.0, + }) + }) + .collect::>>()?; + Some(PlanarCurve::new( + plane, + Curve::Polyline(cadkernel::geom2d::Polyline { vertices, closed }), + )) +} + +fn region_profiles(entity: &EntityType) -> Option<(Plane, Vec>)> { + let EntityType::Region(region) = entity else { + return None; + }; + let plane = planar_polygon_entity(entity)?.plane; + let mut profiles = Vec::with_capacity(region.wires.len()); + for wire in ®ion.wires { + let mut points = wire + .points + .iter() + .map(|point| [point.x, point.y, point.z]) + .collect::>(); + if points.len() > 2 && points.first() == points.last() { + points.pop(); + } + if points.len() < 3 { + return None; + } + let scale = points + .iter() + .flatten() + .map(|value| value.abs()) + .fold(1.0_f64, f64::max); + if points + .iter() + .any(|point| !plane.contains(*point, scale * 1e-9)) + { + return None; + } + let vertices = points + .iter() + .map(|point| { + plane + .project(*point) + .map(|position| cadkernel::geom2d::PolylineVertex { + position, + bulge: 0.0, + }) + }) + .collect::>>()?; + profiles.push( + Curve::Polyline(cadkernel::geom2d::Polyline { + vertices, + closed: true, + }) + .segments(), + ); + } + (!profiles.is_empty()).then_some((plane, profiles)) +} + /// A circle as four arcs. fn quarters(centre: [f64; 2], radius: f64) -> Vec { use std::f64::consts::FRAC_PI_2; @@ -103,10 +258,112 @@ fn split_ellipse(arc: EllipseArc) -> Vec { pub fn extruded(entity: &EntityType, height: f64) -> Option { let profile = profile_of(entity)?; let normal = profile.plane.normal()?; - brep::extrude( + extruded_direction( + entity, + [normal[0] * height, normal[1] * height, normal[2] * height], + 0.0, + ) +} + +pub fn extruded_direction( + entity: &EntityType, + direction: [f64; 3], + taper_angle: f64, +) -> Option { + if let Some((plane, profiles)) = + region_profiles(entity).filter(|(_, profiles)| profiles.len() > 1) + { + if taper_angle.abs() > 1e-12 { + return None; + } + return brep::extrude_region(plane, &profiles, direction); + } + let profile = profile_of(entity)?; + brep::extrude_tapered(profile.plane, &profile.pieces, direction, taper_angle) +} + +pub fn extruded_surface( + entity: &EntityType, + direction: [f64; 3], + taper_angle: f64, +) -> Option { + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + return None; + } + let (profile, _) = extrusion_profile_of(entity)?; + brep::extrude_surface_tapered( profile.plane, &profile.pieces, - [normal[0] * height, normal[1] * height, normal[2] * height], + direction, + taper_angle, + ) +} + +pub fn extruded_along_path( + entity: &EntityType, + path: &EntityType, + taper_angle: f64, +) -> Option { + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + if taper_angle.abs() > 1e-12 { + return None; + } + return extruded_direction(entity, straight_path_direction(path)?, 0.0); + } + let profile = profile_of(entity)?; + if let EntityType::Polyline3D(path) = path { + let points = path + .vertices + .iter() + .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]) + .collect::>(); + if taper_angle.abs() > 1e-12 { + if points.len() != 2 { + return None; + } + return brep::extrude_tapered( + profile.plane, + &profile.pieces, + (Vec3::from(points[1]) - Vec3::from(points[0])).to_array(), + taper_angle, + ); + } + return brep::sweep_along_polyline3d(profile.plane, &profile.pieces, &points); + } + if taper_angle.abs() <= 1e-12 { + return swept(entity, path); + } + let curve = entity_curve(path)?; + let Curve::Line(line) = curve.curve else { + return None; + }; + let direction = Vec3::from(curve.plane.point_at(line.end)) + - Vec3::from(curve.plane.point_at(line.start)); + brep::extrude_tapered( + profile.plane, + &profile.pieces, + direction.to_array(), + taper_angle, + ) +} + +pub fn straight_path_direction(path: &EntityType) -> Option<[f64; 3]> { + if let EntityType::Polyline3D(path) = path { + if path.vertices.len() != 2 { + return None; + } + let start = &path.vertices[0].position; + let end = &path.vertices[1].position; + return Some([end.x - start.x, end.y - start.y, end.z - start.z]); + } + let curve = entity_curve(path)?; + let Curve::Line(line) = curve.curve else { + return None; + }; + Some( + (Vec3::from(curve.plane.point_at(line.end)) + - Vec3::from(curve.plane.point_at(line.start))) + .to_array(), ) } @@ -142,13 +399,40 @@ pub fn revolved( /// SWEEP as an exact B-rep along straight and circular path pieces. pub fn swept(profile: &EntityType, path: &EntityType) -> Option { let profile = profile_of(profile)?; - let path = entity_curve(path)?; + let mut path = entity_curve(path)?; let pieces = match &path.curve { Curve::Line(line) => vec![Curve::Line(*line)], Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Circle(circle) => quarters(circle.centre, circle.radius), Curve::Polyline(_) => path.curve.segments(), + Curve::Ellipse(_) | Curve::Nurbs(_) => { + let scale = path + .curve + .point_at(0.0) + .into_iter() + .chain(path.curve.point_at(1.0)) + .map(f64::abs) + .fold(1.0_f64, f64::max); + path.curve + .tessellate_within(scale * 1e-4) + .windows(2) + .filter(|pair| pair[0] != pair[1]) + .map(|pair| Curve::Line(Line { + start: pair[0], + end: pair[1], + })) + .collect() + } _ => return None, }; + let profile_center = profile + .pieces + .iter() + .map(|piece| Vec3::from(profile.plane.point_at(piece.point_at(0.0)))) + .fold(Vec3::ZERO, |sum, point| sum + point) + / profile.pieces.len() as f64; + let path_start = Vec3::from(path.plane.point_at(path.curve.point_at(0.0))); + path.plane.origin = (Vec3::from(path.plane.origin) + profile_center - path_start).to_array(); brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces) } @@ -174,7 +458,7 @@ pub enum PolysolidJustification { Right, } -fn embedded_path(entity: &EntityType) -> Option { +pub fn embedded_path(entity: &EntityType) -> Option { match entity { EntityType::Line(value) => Some(EmbeddedEntity::Line(value.clone())), EntityType::Arc(value) => Some(EmbeddedEntity::Arc(value.clone())), @@ -182,10 +466,143 @@ fn embedded_path(entity: &EntityType) -> Option { EntityType::Ellipse(value) => Some(EmbeddedEntity::Ellipse(value.clone())), EntityType::LwPolyline(value) => Some(EmbeddedEntity::LwPolyline(value.clone())), EntityType::Spline(value) => Some(EmbeddedEntity::Spline(value.clone())), + EntityType::Polyline3D(value) if !value.is_closed() && value.vertices.len() >= 2 => { + let mut spline = Spline::from_control_points( + 1, + value + .vertices + .iter() + .map(|vertex| vertex.position) + .collect(), + ); + spline.flags.linear = true; + spline.flags.planar = false; + Some(EmbeddedEntity::Spline(spline)) + } _ => None, } } +fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + return None; + } + if !matches!(entity, EntityType::Polyline3D(_)) { + if let Some(embedded) = embedded_path(entity) { + return Some((embedded, glam::DMat4::IDENTITY.to_cols_array())); + } + } + let (profile, closed) = extrusion_profile_of(entity)?; + let mut polyline = LwPolyline::new(); + for piece in &profile.pieces { + let Curve::Line(line) = piece else { + return None; + }; + polyline + .vertices + .push(LwVertex::new(Vector2::new(line.start[0], line.start[1]))); + } + if !closed { + let Curve::Line(last) = profile.pieces.last()? else { + return None; + }; + polyline + .vertices + .push(LwVertex::new(Vector2::new(last.end[0], last.end[1]))); + } + polyline.is_closed = closed; + let normal = profile.plane.normal()?; + let transform = glam::DMat4::from_cols( + glam::DVec4::new( + profile.plane.x_axis[0], + profile.plane.x_axis[1], + profile.plane.x_axis[2], + 0.0, + ), + glam::DVec4::new( + profile.plane.y_axis[0], + profile.plane.y_axis[1], + profile.plane.y_axis[2], + 0.0, + ), + glam::DVec4::new(normal[0], normal[1], normal[2], 0.0), + glam::DVec4::new( + profile.plane.origin[0], + profile.plane.origin[1], + profile.plane.origin[2], + 1.0, + ), + ) + .to_cols_array(); + Some((EmbeddedEntity::LwPolyline(polyline), transform)) +} + +pub fn extrusion_history( + profile: &EntityType, + path: Option<&EntityType>, + direction: [f64; 3], + taper_angle: f64, + reference_point: [f64; 3], +) -> Option { + let signed_height = Vec3::from(direction).length(); + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + let (sweep_entity, sweep_entity_transform) = embedded_planar_entity(profile)?; + let (path_entity, path_entity_transform) = match path { + Some(path) => { + let (entity, transform) = embedded_planar_entity(path)?; + (Some(entity), transform) + } + None => (None, glam::DMat4::IDENTITY.to_cols_array()), + }; + Some(SolidHistoryOperation::Extrusion(SolidHistorySweep { + base, + operation_major: 1, + direction: Vector3::new(direction[0], direction[1], direction[2]), + sweep_entity: Some(sweep_entity), + path_entity, + draft_angle: taper_angle, + start_draft_distance: 0.0, + end_draft_distance: signed_height, + scale_factor: 1.0, + sweep_entity_transform, + path_entity_transform, + reference_point: Vector3::new( + reference_point[0], + reference_point[1], + reference_point[2], + ), + ..SolidHistorySweep::default() + })) +} + +pub fn sweep_history( + profile: &EntityType, + path: &EntityType, + taper_angle: f64, + reference_point: [f64; 3], +) -> Option { + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + let (sweep_entity, sweep_entity_transform) = embedded_planar_entity(profile)?; + Some(SolidHistoryOperation::Sweep(SolidHistorySweep { + base, + operation_major: 1, + sweep_entity: Some(sweep_entity), + path_entity: Some(embedded_path(path)?), + draft_angle: taper_angle, + scale_factor: 1.0, + sweep_entity_transform, + path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + reference_point: Vector3::new( + reference_point[0], + reference_point[1], + reference_point[2], + ), + ..SolidHistorySweep::default() + })) +} + /// Builds a history-backed wall solid along a supported planar curve. pub fn polysolid( entity: &EntityType,