From a1e809b61b26a27bf70b9b4f238ccc75255a5a43 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:13:17 +0300 Subject: [PATCH] Match ordered tangent constraint behavior --- src/app/command_driver.rs | 58 +++++++++++ src/app/commands/draw.rs | 40 ++++++- src/command.rs | 8 ++ src/modules/parametric/mod.rs | 2 + src/modules/parametric/tangent.rs | 168 ++++++++++++++++++++++++++++++ src/scene/mod.rs | 38 ++++++- src/scene/parametric_solve.rs | 86 ++++++++++++++- 7 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 src/modules/parametric/tangent.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 70f498228..515434bc7 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3067,6 +3067,64 @@ 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); + self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Tangent, + refs, + None, + ); + 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 { point_a, point_b, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 7ef3f7b36..ecabfd649 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1345,7 +1345,44 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "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 { + 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" | "QCONSTRAINT" | "ECONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { @@ -1365,7 +1402,6 @@ impl OpenCADStudio { "QCONSTRAINT" => { (ConstraintKind::Perpendicular, "Perpendicular 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 bdc482e3f..e4c44cb10 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1466,6 +1466,14 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. 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 a Coincident constraint between the sub-entity points nearest diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 9539651ee..dd6e16838 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -5,6 +5,7 @@ mod coincident; mod equal_distance; mod point_on_entity; +mod tangent; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; @@ -12,6 +13,7 @@ pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; +pub use tangent::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..ac5266ee8 --- /dev/null +++ b/src/modules/parametric/tangent.rs @@ -0,0 +1,168 @@ +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 + } +} + diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 59eb787f3..fa912d52c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2873,6 +2873,41 @@ impl Scene { driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], + ) { + self.bump_entities_with_parametric_context( + changes, + driven_refs, + retain_size, + retained_originals, + &[], + ); + } + + /// 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_parametric_context( + changes, + &[], + retain_size, + &[], + fixed_refs, + ); + } + + fn bump_entities_with_parametric_context( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + fixed_refs: &[parametric_constraints::ParametricRef], ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) @@ -2907,11 +2942,12 @@ impl Scene { } } if !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 9057a3410..fb688608a 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1605,6 +1605,7 @@ fn solve_scope( driven_refs: &[ParametricRef], retain_size: bool, retained_before: &HashMap>, + initial_fixed_refs: &[ParametricRef], ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1630,6 +1631,28 @@ 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) { + sys.add_constraint(constraint); + } + } + let has_smooth = set .constraints .iter() @@ -1643,6 +1666,12 @@ fn solve_scope( // solution without becoming persistent dimensional constraints. if retain_size { for (handle, geom) in &cache { + if initial_fixed_refs + .iter() + .any(|reference| reference.entity == *handle) + { + continue; + } match geom { EntityGeom::Line(line) => { let current_length = { @@ -1701,12 +1730,37 @@ 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 minor_radius = sys.store().get(ellipse.radmin); + let target = sys.add_param(minor_radius, true); + sys.add_constraint(Rc::new(Equal::new(ellipse.radmin, target, 1.0))); } _ => {} } @@ -2331,6 +2385,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, @@ -2399,11 +2470,17 @@ impl Scene { driven_refs, retain_size, &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)); @@ -2449,6 +2526,7 @@ impl Scene { driven_refs, retain_size, &retained_before, + &[], ) else { continue;