diff --git a/Cargo.lock b/Cargo.lock index df1179392..a7d869b8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7e43598#7e435984d9ba32999d2471930c18449a2ab58d67" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4815518#481551836494e581279fdbfbf8114921170ee75b" dependencies = [ "acadrust", "cavalier_contours", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7e43598#7e435984d9ba32999d2471930c18449a2ab58d67" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4815518#481551836494e581279fdbfbf8114921170ee75b" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 055fe4598..67daaa70e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7e43598", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7e43598" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4815518", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4815518" } 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 bb346aafa..8ffb859ec 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3372,6 +3372,88 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddConcentricConstraint { + first, + second, + label, + } => { + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Concentric, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Task::none(); + } + let scope = self.tabs[i].current_parametric_scope(); + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && matches!(constraint.refs.as_slice(), [a, b] + if (*a == first && *b == second) + || (*a == second && *b == first)) + }) + }); + if duplicate { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("The Concentric constraint already exists."); + return Task::none(); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Concentric, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::AddCoincidentConstraint { first, second, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index c52c96b1e..b82148f14 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1496,29 +1496,41 @@ impl OpenCADStudio { } } - "NCONSTRAINT" => { + "NCONSTRAINT" | "GCCONCENTRIC" => { + use crate::command::CmdResult; + use crate::modules::parametric::ConcentricConstraintCommand; + let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&sel.prompt()); - self.tabs[i].active_cmd = Some(Box::new(sel)); - } else if handles.len() != 2 { - self.command_line.push_output( - "Select exactly two circles/arcs, then run this constraint again.", - ); + let command = ConcentricConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } else { - use crate::command::CmdResult; - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Concentric, - refs: vec![ - ParametricRef::center(handles[0]), - ParametricRef::center(handles[1]), - ], - driving_param: None, - label: "Concentric constraint", - })); + let refs = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + ConcentricConstraintCommand::preselected_reference(entity, *handle) + }) + .collect::>(); + if handles.len() == 2 && refs.len() == 2 && refs[0] != refs[1] { + return Some(self.apply_cmd_result(CmdResult::AddConcentricConstraint { + first: refs[0], + second: refs[1], + label: "Concentric constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.", + ); + let command = refs + .first() + .copied() + .map(ConcentricConstraintCommand::with_first) + .unwrap_or_else(ConcentricConstraintCommand::new); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index dd4bc36da..ed7acfba9 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -834,6 +834,23 @@ bg={bg_ms:.1}ms n={view_count}" .active_cmd .as_ref() .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); + let constraint_cursor_badge = if is_paper { + None + } else { + tab.scene.hover_highlight.and_then(|handle| { + tab.scene + .parametric_constraint_set(tab.current_parametric_scope()) + .and_then(|set| { + set.constraints_touching(handle) + .find(|constraint| { + constraint.kind + == crate::scene::parametric_constraints::ConstraintKind::Concentric + }) + .or_else(|| set.constraints_touching(handle).next()) + }) + .map(|constraint| constraint.kind.glyph_symbol().to_string()) + }) + }; let constraint_glyphs: Vec<( iced::Point, [f32; 2], @@ -912,6 +929,7 @@ bg={bg_ms:.1}ms n={view_count}" constraint_glyphs, self.constraint_glyph_tooltip .map(|kind| crate::t!(kind.label()).into_owned()), + constraint_cursor_badge, ) }; diff --git a/src/command.rs b/src/command.rs index 1704fc0ea..bbba73fc1 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1512,6 +1512,13 @@ pub enum CmdResult { second: crate::scene::parametric_constraints::ParametricRef, label: &'static str, }, + /// Adds an ordered concentric relation. The first picked center remains + /// fixed during the initial solve while the second curve moves rigidly. + AddConcentricConstraint { + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, /// Opens the Auto Constrain settings dialog from the selection prompt. OpenAutoConstrainSettings, /// Adds an ordered Coincident relation. Point/point selections create a @@ -2778,6 +2785,7 @@ mod constraint_registry_tests { "ECONSTRAINT", "TCONSTRAINT", "NCONSTRAINT", + "GCCONCENTRIC", "NRCONSTRAINT", "LCONSTRAINT", "FXCONSTRAINT", diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 3c354c20c..9d30c7e29 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -212,9 +212,9 @@ fn properties(arc: &Arc) -> Vec { ro(t!("Total angle").as_ref(), "total_angle", format_angle(total_angle.to_radians())), ro(t!("Arc length").as_ref(), "arc_length", format_length(arc_length)), ro(t!("Area").as_ref(), "area", format_area(area)), - ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", arc.normal.x)), - ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", arc.normal.y)), - ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", arc.normal.z)), + edit(t!("Normal X").as_ref(), "normal_x", arc.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", arc.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", arc.normal.z), ], }] } @@ -244,6 +244,24 @@ fn apply_geom_prop(arc: &mut Arc, field: &str, value: &str) { "radius" if v > 0.0 => arc.radius = v, "start_angle" => arc.start_angle = v.to_radians(), "end_angle" => arc.end_angle = v.to_radians(), + "normal_x" | "normal_y" | "normal_z" => { + let center = arc.center_wcs(); + let mut normal = glam::DVec3::new(arc.normal.x, arc.normal.y, arc.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => {} + } + if let Some(normal) = normal.try_normalize() { + arc.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (center.x, center.y, center.z), + (normal.x, normal.y, normal.z), + ); + arc.center = acadrust::types::Vector3::new(x, y, z); + } + } _ => {} } } diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 7d596506e..cb3d4268e 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -3,7 +3,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, + center_grip, edit_prop as edit, parse_f64, square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -169,9 +169,9 @@ fn properties(circle: &Circle) -> Vec { edit(t!("Diameter").as_ref(), "diameter", r * 2.0), edit(t!("Circumference").as_ref(), "circumference", 2.0 * PI * r), edit(t!("Area").as_ref(), "area", PI * r * r), - ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", circle.normal.x)), - ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", circle.normal.y)), - ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", circle.normal.z)), + edit(t!("Normal X").as_ref(), "normal_x", circle.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", circle.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", circle.normal.z), ], }] } @@ -202,6 +202,24 @@ fn apply_geom_prop(circle: &mut Circle, field: &str, value: &str) { "diameter" if v > 0.0 => circle.radius = v / 2.0, "circumference" if v > 0.0 => circle.radius = v / (2.0 * PI), "area" if v > 0.0 => circle.radius = (v / PI).sqrt(), + "normal_x" | "normal_y" | "normal_z" => { + let center = circle.center_wcs(); + let mut normal = glam::DVec3::new(circle.normal.x, circle.normal.y, circle.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => {} + } + if let Some(normal) = normal.try_normalize() { + circle.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (center.x, center.y, center.z), + (normal.x, normal.y, normal.z), + ); + circle.center = acadrust::types::Vector3::new(x, y, z); + } + } _ => {} } } diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index 47ef9d58e..71ef1d6bc 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -187,9 +187,9 @@ fn properties(ell: &Ellipse) -> Vec { ro(t!("Start parameter").as_ref(), "start_param", format!("{:.4}", ell.start_parameter)), ro(t!("End parameter").as_ref(), "end_param", format!("{:.4}", ell.end_parameter)), ro(t!("Length").as_ref(), "length", format_length(props.perimeter)), - ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", ell.normal.x)), - ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", ell.normal.y)), - ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", ell.normal.z)), + edit(t!("Normal X").as_ref(), "normal_x", ell.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", ell.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", ell.normal.z), ], }] } @@ -220,6 +220,38 @@ fn apply_geom_prop(ell: &mut Ellipse, field: &str, value: &str) { "ratio" if v > 0.0 => ell.minor_axis_ratio = v, "start_angle" => ell.start_parameter = v.to_radians(), "end_angle" => ell.end_parameter = v.to_radians(), + "normal_x" | "normal_y" | "normal_z" => { + let mut normal = glam::DVec3::new(ell.normal.x, ell.normal.y, ell.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => {} + } + if let Some(normal) = normal.try_normalize() { + let current = glam::DVec3::new( + ell.major_axis.x, + ell.major_axis.y, + ell.major_axis.z, + ); + let length = current.length(); + let projected = current - normal * current.dot(normal); + let major = projected.try_normalize().or_else(|| { + let helper = if normal.x.abs() < 0.8 { + glam::DVec3::X + } else { + glam::DVec3::Y + }; + normal.cross(helper).try_normalize() + }); + if let Some(major) = major { + ell.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + let major = major * length; + ell.major_axis = + acadrust::types::Vector3::new(major.x, major.y, major.z); + } + } + } _ => {} } } diff --git a/src/modules/parametric/concentric.rs b/src/modules/parametric/concentric.rs new file mode 100644 index 000000000..68bbb508f --- /dev/null +++ b/src/modules/parametric/concentric.rs @@ -0,0 +1,158 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::ParametricRef; +use crate::t; + +pub struct ConcentricConstraintCommand { + first: Option, + picked_entity: Option, +} + +impl ConcentricConstraintCommand { + pub fn new() -> Self { + Self { + first: None, + picked_entity: None, + } + } + + pub fn with_first(first: ParametricRef) -> Self { + Self { + first: Some(first), + picked_entity: None, + } + } + + fn invalid_selection() -> CmdResult { + CmdResult::ReportError( + t!("Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.") + .into_owned(), + ) + } + + fn polyline_segment_is_arc(entity: &EntityType, index: usize) -> bool { + match entity { + EntityType::LwPolyline(polyline) => polyline + .vertices + .get(index) + .is_some_and(|vertex| vertex.bulge.abs() > 1.0e-12), + EntityType::Polyline2D(polyline) => polyline + .vertices + .get(index) + .is_some_and(|vertex| vertex.bulge.abs() > 1.0e-12), + _ => false, + } + } + + fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + match entity { + EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) => { + Some(ParametricRef::center(handle)) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let planar = crate::entities::curve::entity_curve(entity)?; + let local = planar.plane.project(point.to_array())?; + let segments = planar.curve.segments(); + let (index, _) = cadkernel::geom2d::nearest_of(segments.iter(), local)?; + matches!(segments.get(index), Some(cadkernel::geom2d::Curve::Arc(_))) + .then(|| ParametricRef::segment_center(handle, index)) + } + _ => None, + } + } + + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) => { + Some(ParametricRef::center(handle)) + } + EntityType::LwPolyline(polyline) => { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segment_count == 1 && Self::polyline_segment_is_arc(entity, 0)) + .then(|| ParametricRef::segment_center(handle, 0)) + } + EntityType::Polyline2D(polyline) => { + let segment_count = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segment_count == 1 && Self::polyline_segment_is_arc(entity, 0)) + .then(|| ParametricRef::segment_center(handle, 0)) + } + _ => None, + } + } +} + +impl CadCommand for ConcentricConstraintCommand { + fn name(&self) -> &'static str { + "GCCONCENTRIC" + } + + fn prompt(&self) -> String { + if self.first.is_some() { + t!("GCCONCENTRIC Select second object:").into_owned() + } else { + t!("GCCONCENTRIC Select first object:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(reference) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_selection(); + }; + let Some(first) = self.first else { + self.first = Some(reference); + return CmdResult::NeedPoint; + }; + if first == reference { + return Self::invalid_selection(); + } + CmdResult::AddConcentricConstraint { + first, + second: reference, + label: "Concentric constraint", + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs index ecf0a57fd..8e0e72405 100644 --- a/src/modules/parametric/geom_constraint.rs +++ b/src/modules/parametric/geom_constraint.rs @@ -21,7 +21,7 @@ impl GeomConstraintCommand { "T" | "TANGENT" => "TCONSTRAINT", "SM" | "SMOOTH" => "GCSMOOTH", "C" | "COINCIDENT" => "GCCOINCIDENT", - "CON" | "CONCENTRIC" => "NCONSTRAINT", + "CON" | "CONCENTRIC" => "GCCONCENTRIC", "COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT", "SY" | "SYMMETRIC" => "SYCONSTRAINT", "E" | "EQUAL" => "ECONSTRAINT", diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 7562bb0e6..ec020c3d3 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -3,6 +3,7 @@ //! resulting constraints through the geometry kernel. mod coincident; +mod concentric; mod equal_distance; mod geom_constraint; #[path = "perpendicular.rs"] @@ -14,6 +15,7 @@ mod tangent_command; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; +pub use concentric::ConcentricConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; pub use geom_constraint::GeomConstraintCommand; pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPick}; @@ -23,8 +25,8 @@ pub use point_on_entity::{ pub use smooth::SmoothConstraintCommand; pub use tangent_command::TangentConstraintCommand; pub use tools::{ - colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric, - tangent, vertical, + colinear, concentric as concentric_tool, equal, fixed, horizontal, normal, parallel, + perpendicular, symmetric, tangent, vertical, }; pub use value::{ angle_tool, dimensional_tools, distance_tool, AngleConstraintCommand, @@ -67,7 +69,7 @@ impl CadModule for ParametricModule { "GCSMOOTH", "Smooth", include_bytes!("../../../assets/icons/constrain/smooth.svg"), )), - RibbonItem::LargeTool(concentric::tool()), + RibbonItem::LargeTool(concentric_tool::tool()), RibbonItem::LargeTool(horizontal::tool()), RibbonItem::LargeTool(symmetric::tool()), RibbonItem::LargeTool(fixed::tool()), @@ -170,7 +172,7 @@ mod tests { groups[0].tools.iter().map(item_id).collect::>(), [ "AUTOCONSTRAIN", "CCONSTRAINT", "PCONSTRAINT", "TCONSTRAINT", - "LCONSTRAINT", "QCONSTRAINT", "GCSMOOTH", "NCONSTRAINT", + "LCONSTRAINT", "QCONSTRAINT", "GCSMOOTH", "GCCONCENTRIC", "HCONSTRAINT", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT", "ECONSTRAINT", "GCVISIBILITY", "GCSHOWALL", "GCHIDEALL", ] diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 49f211727..d0a257a97 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -88,12 +88,12 @@ pub mod concentric { use super::*; pub fn tool() -> ToolDef { ToolDef { - id: "NCONSTRAINT", + id: "GCCONCENTRIC", label: "Concentric", icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/concentric.svg" )), - event: ModuleEvent::Command("NCONSTRAINT".to_string()), + event: ModuleEvent::Command("GCCONCENTRIC".to_string()), } } } @@ -168,6 +168,7 @@ inventory::submit!(crate::command::CommandRegistration { "ECONSTRAINT", "TCONSTRAINT", "NCONSTRAINT", + "GCCONCENTRIC", "NRCONSTRAINT", "LCONSTRAINT", "FXCONSTRAINT", diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index b89216bac..35dc43334 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -454,11 +454,17 @@ impl<'a> GroupBuilder<'a> { return Some(*existing); } let segment_midpoint = ParametricRef::point(handle, marker).segment_midpoint_index(); + let segment_center = ParametricRef::point(handle, marker).segment_center_index(); let polyline = matches!( self.document.get_entity(handle), Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) ); - let (curve_id, point_type) = if let Some(segment) = segment_midpoint { + let (curve_id, point_type) = if let Some(segment) = segment_center { + ( + self.segment_node(handle, segment)?, + implicit_point_type::CENTER, + ) + } else if let Some(segment) = segment_midpoint { ( self.segment_node(handle, segment)?, implicit_point_type::MID, @@ -551,6 +557,9 @@ impl<'a> GroupBuilder<'a> { if r.segment_midpoint_index().is_some() { return self.point_node(r.entity, r.marker?); } + if r.segment_center_index().is_some() { + return self.point_node(r.entity, r.marker?); + } if let Some(segment) = r.segment_index() { return self.segment_node(r.entity, segment); } @@ -2079,6 +2088,9 @@ pub(super) fn native_constraint_set( implicit_point_type::MID => { ParametricRef::segment_midpoint(curve.entity, segment) } + implicit_point_type::CENTER => { + ParametricRef::segment_center(curve.entity, segment) + } _ => continue, } } else { diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 3d0473217..8de5d736d 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -14,8 +14,8 @@ use acadrust::types::{Handle, Vector3}; /// ordered per-entity-type point list when non-negative (0/1 = a line's /// start/end, ...), or names a special case when negative (-3 = a /// circle/arc's center; -2 = a bounded curve's midpoint). Polyline segment -/// and segment-midpoint references use private negative ranges so they stay -/// distinct from vertex markers. + /// segment-midpoint, and curved-segment-center references use private + /// negative ranges so they stay distinct from vertex markers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ParametricRef { pub entity: Handle, @@ -28,6 +28,7 @@ pub struct ParametricRef { const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; const POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE: i32 = -2_000_000; +const POLYLINE_SEGMENT_CENTER_MARKER_BASE: i32 = -3_000_000; const ELLIPSE_MAJOR_AXIS_MARKER: i32 = -4; const ELLIPSE_MINOR_AXIS_MARKER: i32 = -5; const TEXT_BASELINE_MARKER: i32 = -6; @@ -88,10 +89,25 @@ impl ParametricRef { pub fn segment_midpoint_index(self) -> Option { let marker = self.marker?; - (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) + (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE + && marker > POLYLINE_SEGMENT_CENTER_MARKER_BASE) .then(|| (POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - marker) as usize) } + /// Select the center of one curved polyline segment. + pub fn segment_center(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_CENTER_MARKER_BASE - index as i32), + } + } + + pub fn segment_center_index(self) -> Option { + let marker = self.marker?; + (marker <= POLYLINE_SEGMENT_CENTER_MARKER_BASE) + .then(|| (POLYLINE_SEGMENT_CENTER_MARKER_BASE - marker) as usize) + } + /// Select the displayed baseline of a Text or MText entity. pub fn text_baseline(entity: Handle) -> Self { Self { @@ -503,7 +519,7 @@ impl ParametricConstraintSet { /// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted /// to the marker conventions constraint endpoints actually use (whole-entity /// `None`, an ordinary `source_points()` index, the `-3` center case, or a -/// bounded curve/segment midpoint). +/// bounded curve/segment midpoint, or a curved polyline-segment center). /// /// Solver-side registration reads raw entity fields directly. This helper is /// for UI-side consumers that need the current world-space position. @@ -524,12 +540,20 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio let point = curve.point_at(0.5); return Some(Vector3::new(point[0], point[1], point[2])); } - let segment = ParametricRef { + let reference = ParametricRef { entity: Handle::NULL, marker: Some(marker), + }; + if let Some(segment) = reference.segment_center_index() { + let planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let cadkernel::geom2d::Curve::Arc(arc) = curve else { + return None; + }; + let point = planar.plane.point_at(arc.centre); + return Some(Vector3::new(point[0], point[1], point[2])); } - .segment_midpoint_index(); - if let Some(segment) = segment { + if let Some(segment) = reference.segment_midpoint_index() { let planar = crate::entities::curve::entity_curve(entity)?; let curve = planar.curve.segments().into_iter().nth(segment)?; let point = planar.plane.point_at(curve.point_at(0.5)); @@ -831,6 +855,18 @@ fn glyph_placement_for_reference( let anchor = (start + end) * 0.5; return Some((anchor, segment_normal(start, end))); } + if let Some(segment) = r.segment_center_index() { + let planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let on_arc = planar.plane.point_at(curve.point_at(0.5)); + let cadkernel::geom2d::Curve::Arc(arc) = curve else { + return None; + }; + let center = planar.plane.point_at(arc.centre); + let center = Vector3::new(center[0], center[1], center[2]); + let on_arc = Vector3::new(on_arc[0], on_arc[1], on_arc[2]); + return Some((on_arc, on_arc - center)); + } if let Some(segment) = r.segment_index() { let anchor = resolve_point( entity, diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 13d554111..553af54ae 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -513,6 +513,12 @@ fn resolve_constraint_point( if let Some(point) = geometry.point_for_marker(marker) { return Some(point); } + if let Some(segment) = reference + .segment_center_index() + .and_then(|index| geometry.arc_segment(index)) + { + return Some(segment.arc.circle.center); + } let midpoint_line = reference .segment_midpoint_index() @@ -794,6 +800,175 @@ fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[Parametr plane_z.is_some() } +fn concentric_reference_geometry( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option<(Vector3, cadkernel::space::Plane)> { + let marker = reference.marker?; + if marker != -3 && reference.segment_center_index().is_none() { + return None; + } + let entity = document.get_entity(reference.entity)?; + let center = super::parametric_constraints::resolve_point(entity, marker)?; + let plane = crate::entities::curve::entity_curve(entity)?.plane; + Some((center, plane)) +} + +fn concentric_refs_share_plane( + document: &acadrust::CadDocument, + refs: &[ParametricRef], +) -> bool { + let [first, second] = refs else { + return false; + }; + if first == second { + return false; + } + let Some((first_center, first_plane)) = concentric_reference_geometry(document, *first) else { + return false; + }; + let Some((second_center, second_plane)) = concentric_reference_geometry(document, *second) + else { + return false; + }; + let (Some(first_normal), Some(second_normal)) = (first_plane.normal(), second_plane.normal()) + else { + return false; + }; + let parallel = (first_normal[0] * second_normal[0] + + first_normal[1] * second_normal[1] + + first_normal[2] * second_normal[2]) + .abs() + >= 1.0 - 1.0e-9; + let points = [ + first_plane.origin, + second_plane.origin, + [first_center.x, first_center.y, first_center.z], + [second_center.x, second_center.y, second_center.z], + ]; + let tolerance = cadkernel::space::coplanarity_tolerance(&points); + parallel + && first_plane + .distance_to(second_plane.origin) + .is_some_and(|distance| distance.abs() <= tolerance) + && first_plane + .distance_to([second_center.x, second_center.y, second_center.z]) + .is_some_and(|distance| distance.abs() <= tolerance) +} + +fn ref_is_driven(driven: &[ParametricRef], reference: ParametricRef) -> bool { + driven.iter().any(|candidate| { + candidate.entity == reference.entity + && (candidate.marker == reference.marker || candidate.marker.is_none()) + }) +} + +fn apply_spatial_concentric_constraints( + document: &acadrust::CadDocument, + set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], + initial_fixed_refs: &[ParametricRef], + results: &mut Vec<(Handle, EntityType)>, +) { + let current_entity = |handle: Handle, results: &[(Handle, EntityType)]| { + results + .iter() + .rev() + .find(|(candidate, _)| *candidate == handle) + .map(|(_, entity)| entity.clone()) + .or_else(|| document.get_entity(handle).cloned()) + }; + + let mut handles = Vec::::new(); + let mut relations = Vec::new(); + let mut fixed = Vec::new(); + let mut points = Vec::new(); + for constraint in set.constraints.iter().filter(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && !refs_share_supported_plane(document, &constraint.refs) + && concentric_refs_share_plane(document, &constraint.refs) + }) { + let [first, second] = constraint.refs.as_slice() else { + continue; + }; + if first.entity == second.entity { + continue; + } + let (Some(first_entity), Some(second_entity)) = ( + current_entity(first.entity, results), + current_entity(second.entity, results), + ) else { + continue; + }; + let (Some(first_marker), Some(second_marker)) = (first.marker, second.marker) else { + continue; + }; + let (Some(first_center), Some(second_center)) = ( + super::parametric_constraints::resolve_point(&first_entity, first_marker), + super::parametric_constraints::resolve_point(&second_entity, second_marker), + ) else { + continue; + }; + let first_index = handles + .iter() + .position(|handle| *handle == first.entity) + .unwrap_or_else(|| { + handles.push(first.entity); + handles.len() - 1 + }); + let second_index = handles + .iter() + .position(|handle| *handle == second.entity) + .unwrap_or_else(|| { + handles.push(second.entity); + handles.len() - 1 + }); + let first_point = [first_center.x, first_center.y, first_center.z]; + let second_point = [second_center.x, second_center.y, second_center.z]; + relations.push((first_index, first_point, second_index, second_point)); + points.extend([first_point, second_point]); + if ref_is_driven(driven_refs, *first) || ref_is_driven(initial_fixed_refs, *first) { + fixed.push(first_index); + } + if ref_is_driven(driven_refs, *second) || ref_is_driven(initial_fixed_refs, *second) { + fixed.push(second_index); + } + } + if relations.is_empty() { + return; + } + fixed.sort_unstable(); + fixed.dedup(); + let tolerance = cadkernel::space::coplanarity_tolerance(&points).max(MOVE_EPS); + let Some(translations) = cadkernel::space::solve_rigid_point_coincidence( + handles.len(), + &relations, + &fixed, + tolerance, + ) else { + return; + }; + for (handle, translation) in handles.into_iter().zip(translations) { + let translation = glam::DVec3::from_array(translation); + if translation.length() <= tolerance { + continue; + } + let Some(mut entity) = current_entity(handle, results) else { + continue; + }; + crate::scene::view::dispatch::apply_transform( + &mut entity, + &crate::command::EntityTransform::Translate(translation), + ); + if let Some((_, current)) = results.iter_mut().find(|(candidate, _)| *candidate == handle) { + *current = entity; + } else { + results.push((handle, entity)); + } + } +} + fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) -> Option { let value = constraint.driving_param.as_ref()?.resolve(params).ok()?; if !value.is_finite() { @@ -1982,6 +2157,7 @@ fn polyline_ref_order( } let position = if let Some(index) = reference.segment_index() .or_else(|| reference.segment_midpoint_index()) + .or_else(|| reference.segment_center_index()) { 2 * index + 1 } else { @@ -2133,7 +2309,13 @@ fn solve_scope( let has_smooth = constraints.iter() .any(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth); - if cache.is_empty() && !has_smooth { + let has_spatial_concentric = constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && !refs_share_supported_plane(document, &constraint.refs) + && concentric_refs_share_plane(document, &constraint.refs) + }); + if cache.is_empty() && !has_smooth && !has_spatial_concentric { return None; } @@ -3013,6 +3195,13 @@ fn solve_scope( } } apply_smooth_constraints(document, set, &mut results); + apply_spatial_concentric_constraints( + document, + set, + driven_refs, + initial_fixed_refs, + &mut results, + ); Some((results, dof, conflicts)) } @@ -3064,6 +3253,21 @@ impl Scene { } return Ok(()); } + if kind == ConstraintKind::Concentric { + if !concentric_refs_share_plane(&self.document, refs) { + return Err( + "Concentric requires two supported circular curves on the same plane.", + ); + } + if !refs_share_supported_plane(&self.document, refs) { + if refs[0].entity == refs[1].entity { + return Err( + "Concentric cannot move two curved segments of the same object on this plane.", + ); + } + return Ok(()); + } + } if !refs_share_supported_plane(&self.document, refs) { return Err( "Constraint references must be supported entities on the same world-XY plane.", diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 78f72e1ba..1aecaba9e 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -84,6 +84,16 @@ fn draw_tangent_constraint_glyph( frame.stroke(&canvas::Path::line(contact, tangent_end), stroke); } +fn draw_concentric_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + color: Color, +) { + let stroke = canvas::Stroke::default().with_color(color).with_width(1.25); + frame.stroke(&canvas::Path::circle(center, 4.7), stroke.clone()); + frame.stroke(&canvas::Path::circle(center, 2.15), stroke); +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -855,6 +865,7 @@ pub fn selection_overlay<'a>( selection_visual: SelectionVisualOptions, constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, constraint_glyph_tooltip: Option, + constraint_cursor_badge: Option, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -880,6 +891,7 @@ pub fn selection_overlay<'a>( selection_visual, constraint_glyphs, constraint_glyph_tooltip, + constraint_cursor_badge, }) .width(Length::Fill) .height(Length::Fill) @@ -944,6 +956,9 @@ struct SelectionCanvas { constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, /// Localized kind name made visible after the app-level hover dwell. constraint_glyph_tooltip: Option, + /// Symbol shown beside the cursor while it targets an entity that already + /// participates in an enabled geometric constraint. + constraint_cursor_badge: Option, } fn draw_grip_marker( @@ -1842,6 +1857,28 @@ impl canvas::Program for SelectionCanvas { let hole = canvas::Path::circle(Point::new(bx + 6.0, by + 10.5), 1.4); frame.fill(&hole, dark); } + if let Some(label) = &self.constraint_cursor_badge { + let center = Point::new(cp.x + sq + if self.hover_locked { 30.0 } else { 12.0 }, cp.y - sq - 7.0); + let blue = Color::from_rgb8(35, 145, 230); + let badge = canvas::Path::circle(center, 8.5); + frame.fill(&badge, blue); + if label == "ā—Ž" { + draw_concentric_constraint_glyph(&mut frame, center, Color::WHITE); + } else if label == "T" { + draw_tangent_constraint_glyph(&mut frame, center, Color::WHITE); + } else { + frame.fill_text(canvas::Text { + content: label.clone(), + position: center, + color: Color::WHITE, + size: iced::Pixels(11.0), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } + } } } // end !over_viewcube @@ -1929,8 +1966,8 @@ impl canvas::Program for SelectionCanvas { let hovered = cursor .position_in(bounds) .and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point)); - let normal_bg = theme.palette().primary.base.color; - let normal_fg = theme.palette().primary.base.text; + let normal_bg = Color::from_rgb8(103, 109, 118); + let normal_fg = Color::WHITE; // A redundant or conflicting constraint gets the danger palette // instead of the // ordinary primary one — same information a resolver panel @@ -1981,6 +2018,8 @@ impl canvas::Program for SelectionCanvas { draw_tangent_constraint_glyph(&mut frame, glyph_center, fg); } else if label == "G²" { draw_smooth_constraint_glyph(&mut frame, glyph_center, fg); + } else if label == "ā—Ž" { + draw_concentric_constraint_glyph(&mut frame, glyph_center, fg); } else { frame.fill_text(canvas::Text { content: label.clone(),