diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b917fdeba..178b41a0a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3253,6 +3253,69 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddTangentConstraint { + 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::Tangent, + &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 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::Tangent, + 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 e7b6467cd..4c747e0a1 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1405,7 +1405,44 @@ impl OpenCADStudio { } } - "PCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" + "TCONSTRAINT" => { + use crate::command::CmdResult; + use crate::modules::parametric::TangentConstraintCommand; + + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + let command = TangentConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.len() != 2 { + self.command_line + .push_output("Select exactly two tangent-compatible objects."); + } else { + let refs = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + TangentConstraintCommand::preselected_reference(entity, *handle) + }) + .collect::>(); + if refs.len() == 2 && refs[0] != refs[1] { + return Some(self.apply_cmd_result(CmdResult::AddTangentConstraint { + first: refs[0], + second: refs[1], + label: "Tangent constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Invalid selection for Tangent. Select a line, polyline segment, circle, arc or ellipse.", + ); + let command = TangentConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + } + + "PCONSTRAINT" | "ECONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { @@ -1422,7 +1459,6 @@ impl OpenCADStudio { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), - "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), _ => (ConstraintKind::Equal, "Equal constraint"), diff --git a/src/command.rs b/src/command.rs index 657809e4a..4a8f64344 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1487,6 +1487,14 @@ pub enum CmdResult { second_start: crate::scene::parametric_constraints::ParametricRef, label: &'static str, }, + /// Adds an ordered tangent relation. The first picked reference stays + /// fixed during the initial solve and the second keeps its intrinsic + /// shape while it moves into tangency. + AddTangentConstraint { + 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 diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 45bff2fd0..056c17686 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -8,6 +8,8 @@ mod geom_constraint; #[path = "perpendicular.rs"] mod perpendicular_command; mod point_on_entity; +#[path = "tangent.rs"] +mod tangent_command; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; @@ -17,6 +19,7 @@ pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPic pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; +pub use tangent_command::TangentConstraintCommand; pub use tools::{ colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric, tangent, vertical, diff --git a/src/modules/parametric/tangent.rs b/src/modules/parametric/tangent.rs new file mode 100644 index 000000000..0b09f7c09 --- /dev/null +++ b/src/modules/parametric/tangent.rs @@ -0,0 +1,203 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::ParametricRef; +use crate::t; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TangentOperand { + Line, + Circle, + Ellipse, +} + +#[derive(Clone, Copy)] +struct TangentPick { + reference: ParametricRef, + operand: TangentOperand, +} + +pub struct TangentConstraintCommand { + first: Option, + picked_entity: Option, +} + +impl TangentConstraintCommand { + pub fn new() -> Self { + Self { + first: None, + picked_entity: None, + } + } + + fn invalid_selection() -> CmdResult { + CmdResult::ReportError( + t!("Invalid selection for Tangent. Select a line, polyline segment, circle, arc or ellipse.") + .into_owned(), + ) + } + + fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (reference, operand) = match entity { + EntityType::Line(_) => (ParametricRef::whole(handle), TangentOperand::Line), + EntityType::Circle(_) | EntityType::Arc(_) => { + (ParametricRef::whole(handle), TangentOperand::Circle) + } + EntityType::Ellipse(_) => (ParametricRef::whole(handle), TangentOperand::Ellipse), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + (ParametricRef::segment(handle, index), TangentOperand::Line) + } + _ => return None, + }; + Some(TangentPick { reference, operand }) + } + + fn pair_supported(first: TangentOperand, second: TangentOperand) -> bool { + matches!( + (first, second), + (TangentOperand::Circle, TangentOperand::Circle) + | (TangentOperand::Circle, TangentOperand::Line) + | (TangentOperand::Line, TangentOperand::Circle) + | (TangentOperand::Ellipse, TangentOperand::Line) + | (TangentOperand::Line, TangentOperand::Ellipse) + ) + } + + pub fn preselected_reference( + entity: &EntityType, + handle: Handle, + ) -> Option { + match entity { + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(polyline) => { + let segments = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segments == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12) + .then(|| ParametricRef::segment(handle, 0)) + } + EntityType::Polyline2D(polyline) => { + let segments = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segments == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12) + .then(|| ParametricRef::segment(handle, 0)) + } + _ => None, + } + } +} + +impl CadCommand for TangentConstraintCommand { + fn name(&self) -> &'static str { + "TCONSTRAINT" + } + + fn prompt(&self) -> String { + if self.first.is_some() { + t!("TCONSTRAINT Select second object:").into_owned() + } else { + t!("TCONSTRAINT 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(pick) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_selection(); + }; + let Some(first) = self.first else { + self.first = Some(pick); + return CmdResult::NeedPoint; + }; + if first.reference == pick.reference + || !Self::pair_supported(first.operand, pick.operand) + { + return Self::invalid_selection(); + } + CmdResult::AddTangentConstraint { + first: first.reference, + second: pick.reference, + label: "Tangent 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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + #[test] + fn command_preserves_pick_order_for_the_initial_anchor() { + let first = Handle::new(11); + let second = Handle::new(13); + let mut command = TangentConstraintCommand::new(); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(4.0, 0.0, 0.0), + ))); + assert!(matches!( + command.on_entity_pick(first, DVec3::ZERO), + CmdResult::NeedPoint + )); + command.inject_picked_entity(EntityType::Circle(Circle::from_center_radius( + Vector3::new(2.0, 3.0, 0.0), + 1.0, + ))); + let CmdResult::AddTangentConstraint { + first: first_ref, + second: second_ref, + .. + } = command.on_entity_pick(second, DVec3::ZERO) + else { + panic!("second pick must create the constraint"); + }; + assert_eq!(first_ref, ParametricRef::whole(first)); + assert_eq!(second_ref, ParametricRef::whole(second)); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index fc930b3a1..57e7e9264 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3211,12 +3211,38 @@ impl Scene { retain_size: bool, retained_originals: &[(Handle, EntityType)], ) { - self.bump_entities_with_solve_policy(changes, driven_refs, retain_size, retained_originals, true); + self.bump_entities_with_solve_policy( + changes, + driven_refs, + retain_size, + retained_originals, + &[], + true, + ); } /// Refresh display and associations after a solved grip or exact history restore. pub(crate) fn bump_entities_after_parametric_solve(&mut self, changes: &[(Handle, ChangeKind)]) { - self.bump_entities_with_solve_policy(changes, &[], false, &[], false); + self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false); + } + + /// Apply a newly-created ordered relation while temporarily anchoring + /// its reference side. The anchors guide only this first solve and are + /// never added to the persistent constraint graph. + pub fn bump_entities_with_initial_parametric_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + fixed_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + ) { + self.bump_entities_with_solve_policy( + changes, + &[], + retain_size, + &[], + fixed_refs, + true, + ); } fn bump_entities_with_solve_policy( @@ -3225,6 +3251,7 @@ impl Scene { driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], + fixed_refs: &[parametric_constraints::ParametricRef], solve_parametric: bool, ) { if changes.iter().any(|(handle, kind)| { @@ -3260,11 +3287,12 @@ impl Scene { } } if solve_parametric && !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints_with_originals( + for change in self.refresh_parametric_constraints_with_initial_policy( &changes, driven_refs, retain_size, retained_originals, + fixed_refs, ) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 8c3619c90..0fcf6ab37 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1904,6 +1904,7 @@ fn solve_scope( retain_size: bool, retain_lengths: bool, retained_before: &HashMap>, + initial_fixed_refs: &[ParametricRef], ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1960,6 +1961,36 @@ fn solve_scope( } } + // Ordered two-object constraints use the first pick as a reference for + // their initial placement. Build temporary Fixed equations into this + // solve only; they are deliberately omitted from `owner` and from the + // persistent constraint set/native graph. + for reference in initial_fixed_refs { + let temporary = ParametricConstraint { + id: ConstraintId::MAX, + kind: ConstraintKind::Fixed, + refs: vec![*reference], + driving_param: None, + enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: distance_direction_type::UNDIRECTED, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, + }; + for constraint in build_constraint( + document, + &mut sys, + &mut cache, + params, + &temporary, + None, + None, + ) { + sys.add_constraint(constraint); + } + } + let has_smooth = constraints.iter() .any(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth); if cache.is_empty() && !has_smooth { @@ -2090,6 +2121,12 @@ fn solve_scope( // endpoints. Other edits can retain line lengths as well. if retain_size { for (handle, geom) in &cache { + if initial_fixed_refs + .iter() + .any(|reference| reference.entity == *handle) + { + continue; + } match geom { EntityGeom::Line(line) | EntityGeom::TextLine(line) if retain_lengths => { let current_length = { @@ -2145,12 +2182,32 @@ fn solve_scope( sys.add_constraint(Rc::new(Equal::new(circle.rad, target, 1.0))); } EntityGeom::Arc(arc) => { + let (current_start, current_end) = { + let store = sys.store(); + (store.get(arc.start_angle), store.get(arc.end_angle)) + }; let radius = retained_before .get(handle) .and_then(|entity| retained_radius(entity)) .unwrap_or_else(|| sys.store().get(arc.circle.rad)); - let target = sys.add_param(radius, true); - sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); + let target_radius = sys.add_param(radius, true); + let target_start = sys.add_param(current_start, true); + let target_end = sys.add_param(current_end, true); + sys.add_constraint(Rc::new(Equal::new( + arc.circle.rad, + target_radius, + 1.0, + ))); + sys.add_constraint(Rc::new(Equal::new( + arc.start_angle, + target_start, + 1.0, + ))); + sys.add_constraint(Rc::new(Equal::new( + arc.end_angle, + target_end, + 1.0, + ))); } EntityGeom::Ellipse(ellipse) => { let (focus_distance, minor_radius) = { @@ -2954,6 +3011,23 @@ impl Scene { driven_refs: &[ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_initial_policy( + changes, + driven_refs, + retain_size, + retained_originals, + &[], + ) + } + + pub(crate) fn refresh_parametric_constraints_with_initial_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + initial_fixed_refs: &[ParametricRef], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -3023,11 +3097,17 @@ impl Scene { retain_size, true, &retained_before, + initial_fixed_refs, ) else { continue; }; - self.parametric_constraints[i].dof = Some(dof); - self.parametric_constraints[i].conflicts = conflicts; + if initial_fixed_refs.is_empty() { + self.parametric_constraints[i].dof = Some(dof); + self.parametric_constraints[i].conflicts = conflicts; + } else { + self.parametric_constraints[i].dof = None; + self.parametric_constraints[i].conflicts.clear(); + } for (handle, new_entity) in solved { if let Some(before) = self.document.get_entity_arc(handle) { self.record_undo_before(handle, Some(before)); @@ -3078,6 +3158,7 @@ impl Scene { retain_size, false, &retained_before, + &[], ) else { continue;