diff --git a/Cargo.lock b/Cargo.lock index df993e5a8..73374e491 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=1ad942cc34b592917ca5fff9936afb902109a29d#1ad942cc34b592917ca5fff9936afb902109a29d" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 6a5e39342..c7b275d3e 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 = "1ad942cc34b592917ca5fff9936afb902109a29d", 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 150fd1381..bf67a0ef6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3454,42 +3454,172 @@ 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) => { + if delete_sources && self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); } + 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)| { + crate::entities::curve::entity_curve(path).and_then(|curve| { + let cadkernel::geom2d::Curve::Line(line) = curve.curve else { + return None; + }; + let start = glam::DVec3::from_array(curve.plane.point_at(line.start)); + let end = glam::DVec3::from_array(curve.plane.point_at(line.end)); + Some(end - start) + }) + }); + // 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)), _) if taper_angle.abs() <= 1e-12 => { + sweep_model::extruded_along_path(&entity, path) + } + (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(), 0.0) + }), + _ => 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 = sweep_model::extrusion_history( + &entity, + path.as_ref().map(|(_, entity)| entity), + 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() { + if let Some((path_handle, _)) = path { + consumed.push(path_handle); + } + 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..4d9f0c049 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -736,21 +736,27 @@ 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, _)| *handle).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 173ac41a8..4f4a464e6 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 64bb12501..1601b4f77 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 { @@ -1408,10 +1421,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..d3720e3c8 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -3,7 +3,7 @@ use acadrust::{entities::Solid3D, EntityType}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode}; use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── @@ -11,16 +11,25 @@ use crate::t; pub struct ExtrudeCommand { command_name: &'static str, step: ExtrudeStep, - pub target_handle: acadrust::Handle, + handles: Vec, anchor: DVec3, - direction: Option, + profile_direction: Option, + direction_start: Option, + mode: ExtrudeMode, + taper_angle: f64, color: [f32; 4], } -#[derive(PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] enum ExtrudeStep { Pick, + Mode, Height, + DirectionStart, + DirectionEnd, + Path, + Taper, + Expression, } impl ExtrudeCommand { @@ -31,12 +40,39 @@ impl ExtrudeCommand { _ => "EXTRUDE", }, step: ExtrudeStep::Pick, - target_handle: acadrust::Handle::NULL, + handles: Vec::new(), anchor: DVec3::ZERO, - direction: None, + profile_direction: None, + direction_start: None, + mode: ExtrudeMode::Solid, + taper_angle: 0.0, color, } } + + pub fn set_preselection( + &mut self, + handles: Vec, + anchor: DVec3, + direction: Option, + ) { + self.handles = handles; + 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 { + CmdResult::ExtrudeEntities { + handles: self.handles.clone(), + extent, + mode: self.mode, + taper_angle: self.taper_angle, + color: self.color, + } + } } impl CadCommand for ExtrudeCommand { @@ -45,63 +81,170 @@ 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 => t!("EXTRUDE Creation mode [Solid/Surface]:").into_owned(), + ExtrudeStep::Height => t!("EXTRUDE Specify height or [Direction/Path/Taper angle/Expression]:").into_owned(), + 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:").into_owned(), + ExtrudeStep::Taper => t!("EXTRUDE Specify taper angle:").into_owned(), + ExtrudeStep::Expression => t!("EXTRUDE Enter height 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"), + ], + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.step == ExtrudeStep::Pick + matches!(self.step, ExtrudeStep::Pick | 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 { 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; + } + 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 { + matches!( + self.step, + ExtrudeStep::Pick + | ExtrudeStep::Mode + | ExtrudeStep::Height + | ExtrudeStep::Taper + | ExtrudeStep::Expression + ) + } + fn point_step_accepts_keywords(&self) -> bool { self.step == ExtrudeStep::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| CmdResult::ExtrudeEntity { - handle: self.target_handle, - height: h, - color: self.color, - }) + let value = text.trim(); + 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); + } + 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.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::Taper => { + 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; + self.step = ExtrudeStep::Height; + 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))), + _ => None, + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + if self.step == ExtrudeStep::Pick && !self.handles.is_empty() { + self.step = ExtrudeStep::Height; + CmdResult::NeedPoint + } else { + 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 +257,7 @@ 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?)) } } @@ -479,6 +622,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..235b4f48f 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -57,6 +57,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 +112,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Torus(_) | SolidHistoryOperation::Pyramid(_) | SolidHistoryOperation::Sweep(_) + | SolidHistoryOperation::Extrusion(_) ) ) } @@ -125,7 +128,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 +142,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, @@ -889,6 +986,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 +1019,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 +1035,86 @@ 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.abs() <= 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 sign = if height.signum() == value.end_draft_distance.signum() { + 1.0 + } else { + -1.0 + }; + let updated = unit * height.abs() * sign; + 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 +1544,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; diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 247a8e5d1..0c7b16deb 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -32,26 +32,130 @@ pub fn profile_of(entity: &EntityType) -> Option { 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 }), + )) +} + /// A circle as four arcs. fn quarters(centre: [f64; 2], radius: f64) -> Vec { use std::f64::consts::FRAC_PI_2; @@ -103,13 +207,46 @@ 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( - profile.plane, - &profile.pieces, + 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 { + 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 { + let (profile, closed) = extrusion_profile_of(entity)?; + if closed { + brep::extrude_surface_tapered( + profile.plane, + &profile.pieces, + direction, + taper_angle, + ) + } else if taper_angle.abs() <= 1e-12 { + brep::extrude_surface(profile.plane, &profile.pieces, direction) + } else { + None + } +} + +pub fn extruded_along_path(entity: &EntityType, path: &EntityType) -> Option { + swept(entity, path) +} + /// 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()?; @@ -142,13 +279,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 +338,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())), @@ -186,6 +350,98 @@ fn embedded_path(entity: &EntityType) -> Option { } } +fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { + 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 = extrusion_profile_of(profile) + .and_then(|(profile, _)| profile.plane.normal()) + .map(|normal| Vec3::from(direction).dot(Vec3::from(normal))) + .filter(|value| value.is_finite()) + .unwrap_or_else(|| 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() + })) +} + /// Builds a history-backed wall solid along a supported planar curve. pub fn polysolid( entity: &EntityType,