From 1dd6b115cffb1b11b37b517cf19922c8efba74d8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:01:43 +0300 Subject: [PATCH] feat(model): align polysolid workflow --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 15 +- src/app/commands/draw.rs | 40 +- src/app/model_ops.rs | 49 --- src/command.rs | 99 +---- src/modules/model/cylinder_cmd.rs | 1 + src/modules/model/mod.rs | 1 + src/modules/model/polysolid_cmd.rs | 576 +++++++++++++++++++++++++++++ src/modules/model/primitive_cmd.rs | 2 + src/scene/model/solid_history.rs | 140 ++++++- src/scene/model/sweep_model.rs | 180 +++++---- 12 files changed, 857 insertions(+), 250 deletions(-) create mode 100644 src/modules/model/polysolid_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 7bcdc4b0d..9be3e67b8 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=48c634995fa60d3928c37bae49b12b421c56c886#48c634995fa60d3928c37bae49b12b421c56c886" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=f194a6232066452e3198757f4fe25594fd8b090d#f194a6232066452e3198757f4fe25594fd8b090d" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 1536791ee..515542f99 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 = "48c634995fa60d3928c37bae49b12b421c56c886", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "f194a6232066452e3198757f4fe25594fd8b090d", 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 c0bddfc31..150fd1381 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1636,11 +1636,24 @@ impl OpenCADStudio { entity, solid, history, + erase_source, } => { let label = self.history_label_from_active_cmd(i, "SOLID"); - let pending = self.begin_undo(i, label, 1, true); + let erase_source = erase_source.filter(|handle| { + self.tabs[i].scene.document.header.delete_objects + && !self.tabs[i].scene.is_layer_locked(*handle) + }); + let pending = self.begin_undo( + i, + label, + 1 + usize::from(erase_source.is_some()), + true, + ); let handle = self.add_solid_model(entity, *solid, history); if !handle.is_null() { + if let Some(source) = erase_source { + self.tabs[i].scene.erase_entities(&[source]); + } self.tabs[i].dirty = true; if let Some(pd) = pending { self.commit_undo_delta(i, pd); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 5b0c84965..3581f685d 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1038,32 +1038,28 @@ impl OpenCADStudio { return Some(self.solid_convtosurface()); } - // POLYSOLID — extrude a selected polyline into a - // wall-like solid. "POLYSOLID" => { - use crate::command::SelectThenTwoValueCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenTwoValueCommand::new( - "POLYSOLID", - "POLYSOLID wall width:", - "POLYSOLID wall height:", - has_sel, - ); + use crate::modules::model::polysolid_cmd::PolysolidCommand; + let preselected = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .find(|(_, entity)| { + matches!( + entity, + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Circle(_) + | acadrust::EntityType::Ellipse(_) + | acadrust::EntityType::LwPolyline(_) + | acadrust::EntityType::Spline(_) + ) + }) + .map(|(handle, entity)| (handle, entity.clone())); + let c = PolysolidCommand::new(preselected); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } - cmd if cmd.starts_with("POLYSOLID ") => { - let nums: Vec = cmd - .split_whitespace() - .skip(1) - .filter_map(|s| s.parse::().ok()) - .collect(); - if nums.len() >= 2 && nums[0] > 0.0 && nums[1] > 0.0 { - return Some(self.solid_polysolid(nums[0], nums[1])); - } - self.command_line - .push_info(crate::t!("Usage: POLYSOLID (select a polyline first)").as_ref()); - } // SPLINEFIT — fit a smooth spline through the selected polyline's points. "SPLINEFIT" | "FITSPLINE" => { diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 2fe32100d..b92a40783 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -468,55 +468,6 @@ impl super::OpenCADStudio { Task::none() } - /// POLYSOLID — build a wall solid around an exact polyline offset. - pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task { - let i = self.active_tab; - let found: Option<(Handle, EntityType)> = self.tabs[i] - .scene - .selected_entities() - .iter() - .filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h)) - .find_map(|(h, e)| match e { - EntityType::LwPolyline(_) => Some((*h, (*e).clone())), - _ => None, - }); - let Some((handle, entity)) = found else { - self.command_line - .push_error(crate::t!("POLYSOLID: select a polyline first.").as_ref()); - return Task::none(); - }; - let Some(result) = crate::scene::model::sweep_model::polysolid(&entity, width, height) - else { - self.command_line - .push_error(crate::t!("POLYSOLID: the kernel could not offset the polyline.").as_ref()); - return Task::none(); - }; - if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { - self.command_line - .push_error(crate::t!("POLYSOLID: the result could not be encoded as ACIS.").as_ref()); - return Task::none(); - } - self.push_undo_snapshot(i, "POLYSOLID"); - self.tabs[i].scene.erase_entities(&[handle]); - let mut s3d = Solid3D::new(); - s3d.wires = solid_model::edge_wires(&result); - let history = solid_history::brep_op(&result); - let h = self.add_solid_model(EntityType::Solid3D(s3d), result, history); - self.tabs[i].scene.deselect_all(); - if !h.is_null() { - self.tabs[i].scene.select_entity(h, false); - } - if !h.is_null() { - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output( - crate::tf!("POLYSOLID: created a wall solid (width {width}, height {height}).") - .as_ref(), - ); - } - Task::none() - } - /// 3DMIRROR — add a mirrored copy of the one selected solid across the plane /// perpendicular to the X/Y/Z axis (0/1/2) through its centre, keeping the /// original. A reflection loses handedness, so the kernel reverses every diff --git a/src/command.rs b/src/command.rs index 3ab5a167a..64bb12501 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1127,104 +1127,6 @@ impl CadCommand for TCountCommand { } } } -/// Generic front-end for a two-value command that operates on the current -/// selection (POLYSOLID width + height on a selected polyline). Gathers a -/// selection first when none is set, prompts for two values, and dispatches -/// ` ` to the inline handler. -pub struct SelectThenTwoValueCommand { - name: &'static str, - prompt1: &'static str, - prompt2: &'static str, - gathering: bool, - selected: Vec, - first: Option, -} - -impl SelectThenTwoValueCommand { - pub fn new( - name: &'static str, - prompt1: &'static str, - prompt2: &'static str, - has_selection: bool, - ) -> Self { - Self { - name, - prompt1, - prompt2, - gathering: !has_selection, - selected: Vec::new(), - first: None, - } - } -} - -impl CadCommand for SelectThenTwoValueCommand { - fn name(&self) -> &'static str { - self.name - } - - fn prompt(&self) -> String { - if self.gathering { - crate::t!( - "%{name} select objects, then press Enter:", - name = self.name - ) - .into_owned() - } else if self.first.is_none() { - crate::t!(self.prompt1).into_owned() - } else { - crate::t!(self.prompt2).into_owned() - } - } - - fn wants_text_input(&self) -> bool { - !self.gathering - } - - fn is_selection_gathering(&self) -> bool { - self.gathering - } - - fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { - self.selected = handles; - CmdResult::NeedPoint - } - - fn on_text_input(&mut self, text: &str) -> Option { - if self.gathering { - return None; - } - let t = text.trim(); - if t.is_empty() { - return None; - } - match &self.first { - None => { - self.first = Some(t.to_string()); - // Consumed; `None` would feed the same text back as the second - // value and dispatch ` ` in one step. - Some(CmdResult::NeedPoint) - } - Some(first) => Some(CmdResult::Dispatch(format!("{} {first} {t}", self.name))), - } - } - - fn on_point(&mut self, _pt: DVec3) -> CmdResult { - CmdResult::NeedPoint - } - - fn on_enter(&mut self) -> CmdResult { - if self.gathering { - if self.selected.is_empty() { - return CmdResult::Cancel; - } - self.gathering = false; - return CmdResult::NeedPoint; - } - CmdResult::Cancel - } -} - // ── Result token ────────────────────────────────────────────────────────── /// Returned by every `CadCommand` method to tell main.rs what to do. @@ -1278,6 +1180,7 @@ pub enum CmdResult { entity: EntityType, solid: Box, history: acadrust::objects::SolidHistoryOperation, + erase_source: Option, }, /// Commit an acadrust entity, end the command, and open the in-place text /// editor on it (used by MLEADER to type the annotation after placement). diff --git a/src/modules/model/cylinder_cmd.rs b/src/modules/model/cylinder_cmd.rs index 8f1b86c85..dad043283 100644 --- a/src/modules/model/cylinder_cmd.rs +++ b/src/modules/model/cylinder_cmd.rs @@ -219,6 +219,7 @@ impl CylinderCommand { entity: EntityType::Solid3D(entity), solid: Box::new(solid), history, + erase_source: None, } } diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index b3dfb8c26..c430ffc30 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -3,6 +3,7 @@ pub mod boolean_cmd; pub mod cylinder_cmd; pub mod edge_cmd; +pub mod polysolid_cmd; pub mod primitive_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; diff --git a/src/modules/model/polysolid_cmd.rs b/src/modules/model/polysolid_cmd.rs new file mode 100644 index 000000000..f569dd391 --- /dev/null +++ b/src/modules/model/polysolid_cmd.rs @@ -0,0 +1,576 @@ +use std::sync::{Mutex, OnceLock}; + +use acadrust::entities::{LwPolyline, LwVertex, Solid3D}; +use acadrust::types::Vector2; +use acadrust::{EntityType, Handle}; +use glam::{DVec2, DVec3}; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::draw::draw::polyline::{ + arc_sample_points, compute_bulge, seg_exit_tangent, +}; +use crate::scene::model::solid_model; +use crate::scene::model::sweep_model::{self, PolysolidJustification}; +use crate::scene::model::wire_model::WireModel; +use crate::t; + +#[derive(Clone, Copy)] +struct Defaults { + height: f64, + width: f64, + justification: PolysolidJustification, +} + +fn defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| { + Mutex::new(Defaults { + height: 80.0, + width: 5.0, + justification: PolysolidJustification::Center, + }) + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + Start, + Height, + Width, + Justify, + Object, + Line, + Arc, + ArcDirection, + ArcSecond, + ArcEnd, +} + +pub struct PolysolidCommand { + step: Step, + return_step: Step, + plane: WorkingPlane, + vertices: Vec, + bulges: Vec, + height: f64, + width: f64, + justification: PolysolidJustification, + pending_direction: Option, + pending_second: Option, + picked: Option, + preselected: Option<(Handle, EntityType)>, +} + +impl PolysolidCommand { + pub fn new(preselected: Option<(Handle, EntityType)>) -> Self { + let saved = defaults().lock().map(|value| *value).unwrap_or(Defaults { + height: 80.0, + width: 5.0, + justification: PolysolidJustification::Center, + }); + Self { + step: Step::Start, + return_step: Step::Start, + plane: WorkingPlane::default(), + vertices: Vec::new(), + bulges: Vec::new(), + height: saved.height, + width: saved.width, + justification: saved.justification, + pending_direction: None, + pending_second: None, + picked: None, + preselected, + } + } + + fn remember(&self) { + if let Ok(mut saved) = defaults().lock() { + *saved = Defaults { + height: self.height, + width: self.width, + justification: self.justification, + }; + } + } + + fn supported(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Line(_) + | EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::Ellipse(_) + | EntityType::LwPolyline(_) + | EntityType::Spline(_) + ) + } + + fn path_entity(&self, closed: bool) -> Option { + if self.vertices.len() < 2 { + return None; + } + let local = self + .vertices + .iter() + .map(|point| self.plane.to_local(*point)) + .collect::>(); + let mut path = LwPolyline::new(); + path.vertices = local + .iter() + .enumerate() + .map(|(index, point)| { + let mut vertex = LwVertex::new(Vector2::new(point.x, point.y)); + vertex.bulge = self.bulges.get(index).copied().unwrap_or(0.0); + vertex + }) + .collect(); + path.elevation = local[0].z; + path.is_closed = closed; + Some(self.plane.place_entity(EntityType::LwPolyline(path))) + } + + fn commit(&self, path: EntityType, erase_source: Option) -> CmdResult { + let Some((solid, history)) = sweep_model::polysolid( + &path, + self.width, + self.height, + self.justification, + ) else { + return CmdResult::NeedPoint; + }; + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) else { + return CmdResult::Cancel; + }; + let mut entity = Solid3D::new(); + entity.set_sat_document(&document); + entity.wires = solid_model::edge_wires(&solid); + CmdResult::CommitSolid { + entity: EntityType::Solid3D(entity), + solid: Box::new(solid), + history, + erase_source, + } + } + + fn finish(&self, closed: bool) -> CmdResult { + self.path_entity(closed) + .map(|path| self.commit(path, None)) + .unwrap_or(CmdResult::Cancel) + } + + fn close(&mut self) -> CmdResult { + if self.vertices.len() < 3 { + return CmdResult::NeedPoint; + } + if self.step == Step::Arc { + let last = *self.vertices.last().unwrap(); + let first = self.vertices[0]; + let bulge = compute_bulge( + self.plane.to_local(last).truncate(), + self.pending_direction.unwrap_or_else(|| self.last_tangent()), + self.plane.to_local(first).truncate(), + ); + if let Some(value) = self.bulges.last_mut() { + *value = bulge; + } + } + self.finish(true) + } + + fn undo(&mut self) -> CmdResult { + if self.vertices.pop().is_some() { + self.bulges.pop(); + if let Some(last) = self.bulges.last_mut() { + *last = 0.0; + } + } + self.pending_direction = None; + self.pending_second = None; + if self.vertices.is_empty() { + self.step = Step::Start; + } else if matches!(self.step, Step::Arc | Step::ArcDirection | Step::ArcSecond | Step::ArcEnd) + { + self.step = Step::Arc; + } else { + self.step = Step::Line; + } + CmdResult::NeedPoint + } + + fn last_tangent(&self) -> DVec2 { + let count = self.vertices.len(); + if count < 2 { + return DVec2::X; + } + let a = self.plane.to_local(self.vertices[count - 2]); + let b = self.plane.to_local(self.vertices[count - 1]); + seg_exit_tangent(a, b, self.bulges[count - 2]) + .map(|value| value.as_dvec2()) + .unwrap_or(DVec2::X) + } + + fn add_segment(&mut self, point: DVec3, bulge: f64) -> CmdResult { + if let Some(last) = self.bulges.last_mut() { + *last = bulge; + } + self.vertices.push(point); + self.bulges.push(0.0); + self.pending_direction = None; + self.pending_second = None; + CmdResult::NeedPoint + } + + fn three_point_bulge(&self, start: DVec3, middle: DVec3, end: DVec3) -> f64 { + let a3 = self.plane.to_local(start); + let m3 = self.plane.to_local(middle); + let b3 = self.plane.to_local(end); + let a = DVec2::new(a3.x, a3.y); + let m = DVec2::new(m3.x, m3.y); + let b = DVec2::new(b3.x, b3.y); + let determinant = 2.0 + * (a.x * (m.y - b.y) + m.x * (b.y - a.y) + b.x * (a.y - m.y)); + if determinant.abs() <= 1e-12 { + return 0.0; + } + let aa = a.length_squared(); + let mm = m.length_squared(); + let bb = b.length_squared(); + let center = DVec2::new( + (aa * (m.y - b.y) + mm * (b.y - a.y) + bb * (a.y - m.y)) + / determinant, + (aa * (b.x - m.x) + mm * (a.x - b.x) + bb * (m.x - a.x)) + / determinant, + ); + let start_angle = (a - center).y.atan2((a - center).x); + let middle_angle = (m - center).y.atan2((m - center).x); + let end_angle = (b - center).y.atan2((b - center).x); + let ccw = (end_angle - start_angle).rem_euclid(std::f64::consts::TAU); + let middle_ccw = + (middle_angle - start_angle).rem_euclid(std::f64::consts::TAU); + let sweep = if middle_ccw <= ccw + 1e-12 { + ccw + } else { + ccw - std::f64::consts::TAU + }; + (sweep / 4.0).tan() + } + + fn value_step(&mut self, step: Step) { + self.return_step = if self.vertices.is_empty() { + Step::Start + } else { + self.step + }; + self.step = step; + } + + fn path_preview(&self, cursor: DVec3) -> Option { + let start = *self.vertices.last()?; + let start_local = self.plane.to_local(start); + let cursor_local = self.plane.to_local(cursor); + let points = match self.step { + Step::Arc => { + let bulge = compute_bulge( + start_local.truncate(), + self.last_tangent(), + cursor_local.truncate(), + ); + arc_sample_points(start_local.as_vec3(), bulge, cursor_local.as_vec3(), 24) + .into_iter() + .map(|point| self.plane.to_world(DVec3::new( + point[0] as f64, + point[1] as f64, + point[2] as f64, + )).to_array()) + .collect() + } + Step::ArcEnd => { + let middle = self.pending_second?; + let bulge = self.three_point_bulge(start, middle, cursor); + arc_sample_points(start_local.as_vec3(), bulge, cursor_local.as_vec3(), 24) + .into_iter() + .map(|point| self.plane.to_world(DVec3::new( + point[0] as f64, + point[1] as f64, + point[2] as f64, + )).to_array()) + .collect() + } + Step::ArcDirection => vec![start.to_array(), cursor.to_array()], + _ => vec![start.to_array(), cursor.to_array()], + }; + Some(WireModel::solid_f64( + "POLYSOLID_PREVIEW".to_string(), + points, + WireModel::CYAN, + false, + )) + } +} + +impl CadCommand for PolysolidCommand { + fn name(&self) -> &'static str { + "POLYSOLID" + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn prompt(&self) -> String { + match self.step { + Step::Start => format!( + "POLYSOLID Height = {:.4}, Width = {:.4}, Justification = {}\n{}", + self.height, + self.width, + match self.justification { + PolysolidJustification::Left => "Left", + PolysolidJustification::Center => "Center", + PolysolidJustification::Right => "Right", + }, + t!("Specify start point or [Object/Height/Width/Justify] :") + ), + Step::Height => t!("Specify height:").into_owned(), + Step::Width => t!("Specify width:").into_owned(), + Step::Justify => format!( + "Enter justification [Left/Center/Right] <{}>:", + match self.justification { + PolysolidJustification::Left => "Left", + PolysolidJustification::Center => "Center", + PolysolidJustification::Right => "Right", + } + ), + Step::Object => t!("Select path object:").into_owned(), + Step::Line if self.vertices.len() >= 3 => { + t!("Specify next point or [Arc/Close/Undo]:").into_owned() + } + Step::Line => t!("Specify next point or [Arc/Undo]:").into_owned(), + Step::Arc if self.vertices.len() >= 3 => { + t!("Specify endpoint of arc or [Close/Direction/Line/Second point/Undo]:") + .into_owned() + } + Step::Arc => { + t!("Specify endpoint of arc or [Direction/Line/Second point/Undo]:").into_owned() + } + Step::ArcDirection => t!("Specify tangent direction for the start point of arc:").into_owned(), + Step::ArcSecond => t!("Specify second point on arc:").into_owned(), + Step::ArcEnd => t!("Specify end point of arc:").into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(t!("Object").as_ref(), "O"), + CmdOption::new(t!("Height").as_ref(), "H"), + CmdOption::new(t!("Width").as_ref(), "W"), + CmdOption::new(t!("Justify").as_ref(), "J"), + ], + Step::Line => { + let mut options = vec![CmdOption::new(t!("Arc").as_ref(), "A")]; + if self.vertices.len() >= 3 { + options.push(CmdOption::new(t!("Close").as_ref(), "C")); + } + options.push(CmdOption::new(t!("Undo").as_ref(), "U")); + options + } + Step::Arc => { + let mut options = Vec::new(); + if self.vertices.len() >= 3 { + options.push(CmdOption::new(t!("Close").as_ref(), "C")); + } + options.extend([ + CmdOption::new(t!("Direction").as_ref(), "D"), + CmdOption::new(t!("Line").as_ref(), "L"), + CmdOption::new(t!("Second point").as_ref(), "S"), + CmdOption::new(t!("Undo").as_ref(), "U"), + ]); + options + } + _ => Vec::new(), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Start => { + self.vertices.push(point); + self.bulges.push(0.0); + self.step = Step::Line; + CmdResult::NeedPoint + } + Step::Line => self.add_segment(point, 0.0), + Step::Arc => { + let start = *self.vertices.last().unwrap_or(&point); + let a = self.plane.to_local(start).truncate(); + let b = self.plane.to_local(point).truncate(); + let tangent = self.pending_direction.unwrap_or_else(|| self.last_tangent()); + let bulge = compute_bulge(a, tangent, b); + self.add_segment(point, bulge) + } + Step::ArcDirection => { + let start = *self.vertices.last().unwrap_or(&point); + let direction = self.plane.to_local(point) - self.plane.to_local(start); + if direction.truncate().length_squared() > 1e-12 { + self.pending_direction = Some(direction.truncate().normalize()); + self.step = Step::Arc; + } + CmdResult::NeedPoint + } + Step::ArcSecond => { + self.pending_second = Some(point); + self.step = Step::ArcEnd; + CmdResult::NeedPoint + } + Step::ArcEnd => { + let start = *self.vertices.last().unwrap_or(&point); + let middle = self.pending_second.unwrap_or(point); + let bulge = self.three_point_bulge(start, middle, point); + self.step = Step::Arc; + self.add_segment(point, bulge) + } + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Start => { + if let Some((handle, entity)) = self.preselected.take() { + return self.commit(entity, Some(handle)); + } + self.step = Step::Object; + CmdResult::NeedPoint + } + Step::Line | Step::Arc if self.vertices.len() >= 2 => self.finish(false), + Step::Justify => { + self.justification = PolysolidJustification::Center; + self.remember(); + self.step = self.return_step; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, Step::Object | Step::ArcDirection | Step::ArcSecond | Step::ArcEnd) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start | Step::Line | Step::Arc) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = text.trim(); + let keyword = value.to_uppercase(); + match self.step { + Step::Height => { + let number = crate::entities::common::parse_length(value)?; + if number > 0.0 && number.is_finite() { + self.height = number; + self.remember(); + self.step = self.return_step; + } + Some(CmdResult::NeedPoint) + } + Step::Width => { + let number = crate::entities::common::parse_length(value)?; + if number > 0.0 && number.is_finite() { + self.width = number; + self.remember(); + self.step = self.return_step; + } + Some(CmdResult::NeedPoint) + } + Step::Justify => { + self.justification = match keyword.as_str() { + "L" | "LEFT" => PolysolidJustification::Left, + "C" | "CENTER" => PolysolidJustification::Center, + "R" | "RIGHT" => PolysolidJustification::Right, + _ => return None, + }; + self.remember(); + self.step = self.return_step; + Some(CmdResult::NeedPoint) + } + Step::Start => match keyword.as_str() { + "O" | "OBJECT" => { + self.step = Step::Object; + Some(CmdResult::NeedPoint) + } + "H" | "HEIGHT" => { + self.value_step(Step::Height); + Some(CmdResult::NeedPoint) + } + "W" | "WIDTH" => { + self.value_step(Step::Width); + Some(CmdResult::NeedPoint) + } + "J" | "JUSTIFY" => { + self.value_step(Step::Justify); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Step::Line => match keyword.as_str() { + "A" | "ARC" => { + self.step = Step::Arc; + Some(CmdResult::NeedPoint) + } + "C" | "CLOSE" if self.vertices.len() >= 3 => Some(self.close()), + "U" | "UNDO" => Some(self.undo()), + _ => None, + }, + Step::Arc => match keyword.as_str() { + "D" | "DIRECTION" => { + self.step = Step::ArcDirection; + Some(CmdResult::NeedPoint) + } + "L" | "LINE" => { + self.step = Step::Line; + Some(CmdResult::NeedPoint) + } + "S" | "SECOND" | "SECOND POINT" => { + self.step = Step::ArcSecond; + Some(CmdResult::NeedPoint) + } + "C" | "CLOSE" if self.vertices.len() >= 3 => Some(self.close()), + "U" | "UNDO" => Some(self.undo()), + _ => None, + }, + _ => None, + } + } + + fn on_undo_step(&mut self) -> Option { + (!self.vertices.is_empty()).then(|| self.undo()) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Object + } + + fn inject_before_entity_pick(&self) -> bool { + self.step == Step::Object + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + let Some(entity) = self.picked.take() else { + return CmdResult::NeedPoint; + }; + if !Self::supported(&entity) { + return CmdResult::NeedPoint; + } + self.commit(entity, Some(handle)) + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + self.path_preview(point) + } +} diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index cd7d95537..3e4fcdb24 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -472,6 +472,7 @@ impl PrimitiveCommand { entity: EntityType::Solid3D(entity), solid: Box::new(solid), history, + erase_source: None, } } @@ -1342,6 +1343,7 @@ impl PrimitiveCommand { entity: EntityType::Solid3D(entity), solid: Box::new(placed), history, + erase_source: None, } } None => CmdResult::Cancel, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 5043a52e2..d1f2a59b3 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -2,7 +2,7 @@ use acadrust::entities::Solid3D; use acadrust::objects::{ DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation, - SolidHistoryPyramid, SolidHistorySphere, SolidHistoryTorus, + SolidHistoryPyramid, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; use cadkernel::brep::Body; @@ -21,6 +21,7 @@ pub const GRIP_INNER_RADIUS: usize = 10_006; pub const GRIP_SIDES: usize = 10_007; pub const GRIP_MAJOR_RADIUS: usize = 10_008; pub const GRIP_MINOR_RADIUS: usize = 10_009; +pub const GRIP_POSITION: usize = 10_010; 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; @@ -99,6 +100,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Sphere(_) | SolidHistoryOperation::Cone(_) | SolidHistoryOperation::Cylinder(_) + | SolidHistoryOperation::Sweep(_) ) ) } @@ -114,10 +116,77 @@ 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( + value.base.transform, + [ + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + ], + ), _ => None, } } +fn sweep_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistorySweep, +) -> Vec { + let Some(position) = reference_point(&SolidHistoryOperation::Sweep(value.clone())) else { + return Vec::new(); + }; + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, _) = + displayed_history_state(object_show_history, show_history_mode); + let show_value = if show_history { "Yes" } else { "No" }; + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Sweep").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, + ), + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::ReadOnly( + if record_history { "Record" } else { "None" }.to_string(), + ), + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: PropValue::ReadOnly(show_value.to_string()), + }, + ], + }, + ] +} + fn cone_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -558,6 +627,9 @@ pub fn primitive_properties( SolidHistoryOperation::Cylinder(value) => { return cylinder_properties(document, handle, value) } + SolidHistoryOperation::Sweep(value) => { + return sweep_properties(document, handle, value) + } SolidHistoryOperation::Torus(value) => vec![ history_prop( t!("Outer Radius").as_ref(), @@ -812,6 +884,40 @@ fn apply_cylinder_position_property( Some(true) } +fn apply_sweep_position_property( + value: &mut SolidHistorySweep, + field: &str, + text: &str, +) -> Option { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + 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) +} + fn canonicalize_cylinder_radii(value: &mut SolidHistoryCylinder) -> bool { if value.minor_radius <= value.major_radius { value.x_radius = value.major_radius; @@ -856,6 +962,11 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Sweep(sweep_value) = operation { + if let Some(applied) = apply_sweep_position_property(sweep_value, field, value) { + return applied; + } + } let Some(number) = (if field == PROP_SIDES { value.trim().parse::().ok() } else { @@ -1334,6 +1445,17 @@ pub fn primitive_grips( None, ); } + SolidHistoryOperation::Sweep(value) => add( + GRIP_POSITION, + value.base.transform, + [ + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + ], + GripShape::Square, + None, + ), _ => {} } grips @@ -1488,6 +1610,22 @@ pub fn apply_primitive_grip( } _ => return false, }, + SolidHistoryOperation::Sweep(value) if grip_id == GRIP_POSITION => { + let Some(current) = matrix(value.base.transform) else { + return false; + }; + let reference = current.transform_point3(glam::DVec3::new( + value.reference_point.x, + value.reference_point.y, + value.reference_point.z, + )); + let delta = world - reference; + if !delta.is_finite() || delta.length_squared() <= 1e-24 { + return false; + } + value.base.transform = + (glam::DMat4::from_translation(delta) * current).to_cols_array(); + } _ => return false, } true diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 124e53fd0..9cc75bd02 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -1,8 +1,13 @@ // Exact profile sweeps stored as kernel B-reps and ACIS. use cadkernel::brep::{self, Body}; -use cadkernel::geom2d::{offset_polyline, Arc, Curve, EllipseArc, Line, Polyline}; +use cadkernel::geom2d::{Arc, Curve, EllipseArc, Line}; use cadkernel::space::{PlanarCurve, Plane, Vec3}; +use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex}; +use acadrust::objects::{ + SolidHistoryNodeBase, SolidHistoryOperation, SolidHistorySweep, +}; +use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; use crate::entities::curve::entity_curve; @@ -162,88 +167,109 @@ pub fn lofted(profiles: &[EntityType]) -> Option { brep::loft(§ions) } -/// Builds an exact wall solid around a polyline centreline. -pub fn polysolid(entity: &EntityType, width: f64, height: f64) -> Option { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PolysolidJustification { + Left, + Center, + Right, +} + +fn embedded_path(entity: &EntityType) -> Option { + match entity { + EntityType::Line(value) => Some(EmbeddedEntity::Line(value.clone())), + EntityType::Arc(value) => Some(EmbeddedEntity::Arc(value.clone())), + EntityType::Circle(value) => Some(EmbeddedEntity::Circle(value.clone())), + EntityType::Ellipse(value) => Some(EmbeddedEntity::Ellipse(value.clone())), + EntityType::LwPolyline(value) => Some(EmbeddedEntity::LwPolyline(value.clone())), + EntityType::Spline(_) => { + let planar = entity_curve(entity)?; + let samples = planar.curve.tessellate_within(1e-4); + if samples.len() < 2 { + return None; + } + let mut path = LwPolyline::new(); + path.vertices = samples + .into_iter() + .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) + .collect(); + path.is_closed = planar.curve.is_closed(); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(planar.plane.origin), + glam::DVec3::from_array(planar.plane.x_axis), + glam::DVec3::from_array(planar.plane.y_axis), + ); + let EntityType::LwPolyline(path) = plane.place_entity(EntityType::LwPolyline(path)) + else { + return None; + }; + Some(EmbeddedEntity::LwPolyline(path)) + } + _ => None, + } +} + +/// Builds a history-backed wall solid along a supported planar curve. +pub fn polysolid( + entity: &EntityType, + width: f64, + height: f64, + justification: PolysolidJustification, +) -> Option<(Body, SolidHistoryOperation)> { if !width.is_finite() || !height.is_finite() || width <= 0.0 || height.abs() <= 1e-12 { return None; } let planar = entity_curve(entity)?; - let Curve::Polyline(source) = planar.curve else { + let start_local = planar.curve.point_at(0.0); + let tangent_local = Vec3::from([ + planar.curve.tangent_at(0.0)[0], + planar.curve.tangent_at(0.0)[1], + 0.0, + ]) + .normalize()?; + let start = Vec3::from(planar.plane.point_at(start_local)); + let tangent = (Vec3::from(planar.plane.x_axis) * tangent_local.x + + Vec3::from(planar.plane.y_axis) * tangent_local.y) + .normalize()?; + let normal = Vec3::from(planar.plane.normal()?).normalize()?; + let side = tangent.cross(normal).normalize()?; + let offset = match justification { + PolysolidJustification::Left => 0.0, + PolysolidJustification::Center => -width * 0.5, + PolysolidJustification::Right => -width, + }; + let profile_origin = start + side * offset; + let profile_plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(profile_origin.to_array()), + glam::DVec3::from_array(side.to_array()), + glam::DVec3::from_array(normal.to_array()), + ); + let mut profile = LwPolyline::new(); + profile.vertices = [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]] + .into_iter() + .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) + .collect(); + profile.is_closed = true; + let EntityType::LwPolyline(profile) = + profile_plane.place_entity(EntityType::LwPolyline(profile)) + else { return None; }; - let (left_pick, right_pick) = offset_picks(&source, width)?; - let mut left = offset_polyline(&source, width * 0.5, left_pick); - let mut right = offset_polyline(&source, width * 0.5, right_pick); - if left.len() != 1 || right.len() != 1 { - return None; - } - let left = left.remove(0); - let right = right.remove(0); - let normal = Vec3::from(planar.plane.normal()?); - let direction = (normal * height).to_array(); - - if source.closed { - if !left.closed || !right.closed { - return None; - } - let mut profiles = [left, right] - .into_iter() - .map(|polyline| { - let area = Curve::Polyline(polyline.clone()).enclosed_area().abs(); - (area, Curve::Polyline(polyline).segments()) - }) - .collect::>(); - profiles.sort_by(|first, second| second.0.total_cmp(&first.0)); - if profiles[0].0 <= profiles[1].0 || profiles[1].0 <= 1e-12 { - return None; - } - return brep::extrude_region( - planar.plane, - &[profiles.remove(0).1, profiles.remove(0).1], - direction, - ); - } - - if left.closed || right.closed { - return None; - } - let left_start = left.vertices.first()?.position; - let left_end = left.vertices.last()?.position; - let right_start = right.vertices.first()?.position; - let right_end = right.vertices.last()?.position; - let mut outline = Curve::Polyline(left).segments(); - outline.push(Curve::Line(Line { - start: left_end, - end: right_end, - })); - let mut back = Curve::Polyline(right).segments(); - back.reverse(); - outline.extend(back); - outline.push(Curve::Line(Line { - start: right_start, - end: left_start, - })); - brep::extrude(planar.plane, &outline, direction) -} - -fn offset_picks(polyline: &Polyline, width: f64) -> Option<([f64; 2], [f64; 2])> { - let start = polyline.vertices.first()?.position; - let next = polyline.vertices.get(1)?.position; - let along = if let Some(arc) = polyline.segment_arc(0) { - let near = arc.sample(1e-6); - [near[0] - start[0], near[1] - start[1]] - } else { - [next[0] - start[0], next[1] - start[1]] - }; - let length = along[0].hypot(along[1]); - if length <= 1e-12 { - return None; - } - let side = [-along[1] / length * width, along[0] / length * width]; - Some(( - [start[0] + side[0], start[1] + side[1]], - [start[0] - side[0], start[1] - side[1]], - )) + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + let operation = SolidHistoryOperation::Sweep(SolidHistorySweep { + base, + operation_major: 1, + direction: Vector3::new(normal.x * height, normal.y * height, normal.z * height), + sweep_entity: Some(EmbeddedEntity::LwPolyline(profile)), + path_entity: Some(embedded_path(entity)?), + scale_factor: 1.0, + sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + reference_point: Vector3::new(start.x, start.y, start.z), + ..SolidHistorySweep::default() + }); + let body = cadkernel::acis::rebuild_body(&operation).ok()?; + Some((body, operation)) } fn circular_loft(profiles: &[EntityType]) -> Option {