From 9aadc97c11a5ddc1c50aed983e65b6387868b470 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:27:00 +0300 Subject: [PATCH] feat: complete symmetric constraint behavior --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/command_driver.rs | 135 ++++++++++- src/app/commands/draw.rs | 53 ++-- src/app/properties.rs | 14 +- src/app/update/command.rs | 34 ++- src/command.rs | 19 ++ src/modules/parametric/mod.rs | 3 + src/modules/parametric/symmetric.rs | 360 ++++++++++++++++++++++++++++ src/scene/parametric_constraints.rs | 56 +++-- src/scene/parametric_solve.rs | 141 +++++++++-- 11 files changed, 755 insertions(+), 68 deletions(-) create mode 100644 src/modules/parametric/symmetric.rs diff --git a/Cargo.lock b/Cargo.lock index bfb39bf61..742167d3f 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=d2a288b#d2a288b5f397ad7941719119f216a048d6cdcc6f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=0493b59#0493b59a06980e3b5b07bf3c73df0725a2e56c86" 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=d2a288b#d2a288b5f397ad7941719119f216a048d6cdcc6f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=0493b59#0493b59a06980e3b5b07bf3c73df0725a2e56c86" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 062ce7acb..92d6c7ca7 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 = "d2a288b", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d2a288b" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "0493b59", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "0493b59" } 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 6de0b3768..d76f6b058 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -150,9 +150,14 @@ impl OpenCADStudio { entity, target.handle, target.grip_id, )).unwrap_or_default() }).collect(); + let retain_size = self.constraint_solve_mode + && !driven_refs.is_empty() + && driven_refs.iter().all(|reference| reference.marker.is_some()); let solved = self.tabs[i].scene.solve_parametric_constraints_preview( - &touched, &driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), &self.grip_originals, + &touched, + &driven_refs, + retain_size, + &self.grip_originals, ); for (handle, entity) in solved { if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -3388,6 +3393,132 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddSymmetricConstraint { + selection, + axis, + label, + } => { + use crate::command::SymmetricConstraintSelection; + use crate::scene::parametric_constraints::ConstraintKind; + + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |point: glam::DVec3| { + acadrust::types::Vector3::new(point.x, point.y, point.z) + }; + let resolve_point = |pick: crate::command::CoincidentPick| { + if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + None, + ) + } + }; + let (first, second) = match selection { + SymmetricConstraintSelection::Objects(first, second) => (first, second), + SymmetricConstraintSelection::Points(first, second) => { + let (Some(first), Some(second)) = + (resolve_point(first), resolve_point(second)) + else { + self.command_line.push_error( + "Symmetric: select supported endpoints, centers, midpoints, or vertices.", + ); + return Task::none(); + }; + (first, second) + } + }; + if first == second + || axis.entity == first.entity + || axis.entity == second.entity + { + self.command_line + .push_error("Symmetric: select two different references and a separate line axis."); + return Task::none(); + } + let refs = vec![first, second, axis]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Symmetric, + &refs, + None, + ) { + self.command_line.push_error(message); + return Task::none(); + } + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && matches!(constraint.refs.as_slice(), [a, b, m] + if *m == axis + && ((*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 Symmetric 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 mut touched = Vec::new(); + for reference in &refs { + if !touched.contains(&reference.entity) { + touched.push(reference.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::Symmetric, + 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, axis], + false, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("Symmetric constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::AddPerpendicularConstraint { first, second, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 521dd44d4..ad3a5099e 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1618,27 +1618,40 @@ impl OpenCADStudio { "SYCONSTRAINT" => { 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() != 3 { - self.command_line.push_output( - "Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.", - ); + use crate::modules::parametric::SymmetricConstraintCommand; + let command = SymmetricConstraintCommand::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::Symmetric, - refs: vec![ - ParametricRef::center(handles[0]), - ParametricRef::center(handles[1]), - ParametricRef::whole(handles[2]), - ], - driving_param: None, - label: "Symmetric constraint", - })); + use crate::command::{ + CmdResult, SymmetricConstraintSelection, + }; + use crate::modules::parametric::SymmetricConstraintCommand; + + let refs = (handles.len() == 3).then(|| { + let first = self.tabs[i].scene.document.get_entity(handles[0])?; + let second = self.tabs[i].scene.document.get_entity(handles[1])?; + let axis = self.tabs[i].scene.document.get_entity(handles[2])?; + SymmetricConstraintCommand::preselected_refs( + (first, handles[0]), + (second, handles[1]), + (axis, handles[2]), + ) + }).flatten(); + if let Some([first, second, axis]) = refs { + return Some(self.apply_cmd_result(CmdResult::AddSymmetricConstraint { + selection: SymmetricConstraintSelection::Objects(first, second), + axis, + label: "Symmetric constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Symmetric: select two compatible objects followed by a line axis.", + ); + let command = SymmetricConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } } diff --git a/src/app/properties.rs b/src/app/properties.rs index f7438bcc0..3a2e33bde 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2791,7 +2791,16 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} handles: &[Handle], driven_refs: &[crate::scene::parametric_constraints::ParametricRef], ) { - self.invalidate_property_targets_with_originals(i, handles, driven_refs, &[]); + let retain_size = self.constraint_solve_mode + && !driven_refs.is_empty() + && driven_refs.iter().all(|reference| reference.marker.is_some()); + self.invalidate_property_targets_with_originals( + i, + handles, + driven_refs, + retain_size, + &[], + ); } pub(super) fn invalidate_property_targets_with_originals( @@ -2799,6 +2808,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} i: usize, handles: &[Handle], driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + retain_size: bool, retained_originals: &[(Handle, acadrust::EntityType)], ) { let mut context_object_changed = false; @@ -2835,7 +2845,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .bump_entities_with_parametric_originals( &changes, driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), + retain_size, retained_originals, ); } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index c2e7715eb..94502699b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3263,15 +3263,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { - let driven_marker = match field { - "start_x" | "start_y" | "start_z" => Some(0), - "end_x" | "end_y" | "end_z" => Some(1), - "center_x" | "center_y" | "center_z" => Some(-3), + let driven_reference = match field { + "start_x" | "start_y" | "start_z" => Some(Some(0)), + "end_x" | "end_y" | "end_z" => Some(Some(1)), + "center_x" | "center_y" | "center_z" => Some(Some(-3)), + "radius" | "diameter" | "circumference" | "area" + | "major_r" | "minor_r" | "ratio" => Some(None), _ => None, }; - let retained_originals: Vec<_> = if self.constraint_solve_mode - && driven_marker.is_some() - { + let retain_size = self.constraint_solve_mode + && driven_reference.is_some_and(|marker| marker.is_some()); + let retained_originals: Vec<_> = if retain_size { handles .iter() .filter_map(|handle| { @@ -3602,13 +3604,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - let driven_refs: Vec<_> = driven_marker + let driven_refs: Vec<_> = driven_reference .into_iter() .flat_map(|marker| { - handles.iter().copied().map(move |handle| { - crate::scene::parametric_constraints::ParametricRef::point( - handle, marker, - ) + handles.iter().copied().map(move |handle| match marker { + Some(marker) => { + crate::scene::parametric_constraints::ParametricRef::point( + handle, marker, + ) + } + None => { + crate::scene::parametric_constraints::ParametricRef::whole( + handle, + ) + } }) }) .collect(); @@ -3616,6 +3625,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { i, &handles, &driven_refs, + retain_size, &retained_originals, ); self.tabs[i].dirty = true; diff --git a/src/command.rs b/src/command.rs index 356048dd9..925c54650 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1258,6 +1258,18 @@ pub enum HorizontalConstraintSelection { Points(CoincidentPick, CoincidentPick), } +/// The two input forms accepted by the Symmetric geometric constraint. +/// Object picks retain their curve or segment references; point picks are +/// resolved by the host against the live document. +#[derive(Clone, Copy, Debug)] +pub enum SymmetricConstraintSelection { + Objects( + crate::scene::parametric_constraints::ParametricRef, + crate::scene::parametric_constraints::ParametricRef, + ), + Points(CoincidentPick, CoincidentPick), +} + /// Construction options shared by SWEEP creation and its live preview. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SweepOptions { @@ -1512,6 +1524,13 @@ pub enum CmdResult { direction: acadrust::types::Vector3, label: &'static str, }, + /// Adds a point or object symmetry relation around a picked line. The + /// first reference and axis remain fixed during initial placement. + AddSymmetricConstraint { + selection: SymmetricConstraintSelection, + axis: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, /// Adds an ordered perpendicular relation. The first picked direction and /// the second direction's start point stay fixed during the initial solve; /// those temporary anchors are not persisted as geometric constraints. diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index f40701cef..7c0bf79cc 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -13,6 +13,8 @@ mod horizontal_command; mod perpendicular_command; mod point_on_entity; mod smooth; +#[path = "symmetric.rs"] +mod symmetric_command; #[path = "tangent.rs"] mod tangent_command; mod tools; @@ -28,6 +30,7 @@ pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; pub use smooth::SmoothConstraintCommand; +pub use symmetric_command::SymmetricConstraintCommand; pub use tangent_command::TangentConstraintCommand; pub use tools::{ colinear, concentric as concentric_tool, equal, fixed, horizontal, normal, parallel, diff --git a/src/modules/parametric/symmetric.rs b/src/modules/parametric/symmetric.rs new file mode 100644 index 000000000..7557fd04a --- /dev/null +++ b/src/modules/parametric/symmetric.rs @@ -0,0 +1,360 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, CoincidentPick, SymmetricConstraintSelection, +}; +use crate::scene::parametric_constraints::{is_parametric_point_near, ParametricRef}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ObjectFamily { + Line, + Circular, + Ellipse, +} + +#[derive(Clone, Copy)] +struct ObjectPick { + reference: ParametricRef, + family: ObjectFamily, +} + +#[derive(Clone, Copy)] +enum Step { + ObjectOrTwoPoints, + SecondObject(ObjectPick), + FirstPoint, + SecondPoint(CoincidentPick), + ObjectAxis(ObjectPick, ObjectPick), + PointAxis(CoincidentPick, CoincidentPick), +} + +pub struct SymmetricConstraintCommand { + step: Step, + picked_entity: Option, +} + +impl SymmetricConstraintCommand { + pub fn new() -> Self { + Self { + step: Step::ObjectOrTwoPoints, + picked_entity: None, + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn segment_count(entity: &EntityType) -> Option { + match entity { + EntityType::LwPolyline(polyline) => Some(if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }), + EntityType::Polyline2D(polyline) => Some(if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }), + _ => None, + } + } + + fn segment_family(entity: &EntityType, index: usize) -> Option { + let bulge = match entity { + EntityType::LwPolyline(polyline) => polyline.vertices.get(index)?.bulge, + EntityType::Polyline2D(polyline) => polyline.vertices.get(index)?.bulge, + _ => return None, + }; + Some(if bulge.abs() <= 1.0e-12 { + ObjectFamily::Line + } else { + ObjectFamily::Circular + }) + } + + fn picked_segment(entity: &EntityType, point: DVec3) -> Option<(usize, ObjectFamily)> { + 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)?; + Some((index, Self::segment_family(entity, index)?)) + } + + fn picked_object(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (reference, family) = match entity { + EntityType::Line(_) => (ParametricRef::whole(handle), ObjectFamily::Line), + EntityType::Circle(_) | EntityType::Arc(_) => { + (ParametricRef::whole(handle), ObjectFamily::Circular) + } + EntityType::Ellipse(_) => (ParametricRef::whole(handle), ObjectFamily::Ellipse), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (index, family) = Self::picked_segment(entity, point)?; + (ParametricRef::segment(handle, index), family) + } + _ => return None, + }; + Some(ObjectPick { reference, family }) + } + + fn preselected_object(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) => Some(ObjectPick { + reference: ParametricRef::whole(handle), + family: ObjectFamily::Line, + }), + EntityType::Circle(_) | EntityType::Arc(_) => Some(ObjectPick { + reference: ParametricRef::whole(handle), + family: ObjectFamily::Circular, + }), + EntityType::Ellipse(_) => Some(ObjectPick { + reference: ParametricRef::whole(handle), + family: ObjectFamily::Ellipse, + }), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) + if Self::segment_count(entity) == Some(1) => + { + Some(ObjectPick { + reference: ParametricRef::segment(handle, 0), + family: Self::segment_family(entity, 0)?, + }) + } + _ => None, + } + } + + fn picked_axis(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + match entity { + EntityType::Line(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (index, family) = Self::picked_segment(entity, point)?; + (family == ObjectFamily::Line).then_some(ParametricRef::segment(handle, index)) + } + _ => None, + } + } + + fn preselected_axis(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) + if Self::segment_count(entity) == Some(1) + && Self::segment_family(entity, 0) == Some(ObjectFamily::Line) => + { + Some(ParametricRef::segment(handle, 0)) + } + _ => None, + } + } + + pub fn preselected_refs( + first: (&EntityType, Handle), + second: (&EntityType, Handle), + axis: (&EntityType, Handle), + ) -> Option<[ParametricRef; 3]> { + let first = Self::preselected_object(first.0, first.1)?; + let second = Self::preselected_object(second.0, second.1)?; + let axis = Self::preselected_axis(axis.0, axis.1)?; + (first.family == second.family + && first.reference != second.reference + && axis.entity != first.reference.entity + && axis.entity != second.reference.entity) + .then_some([first.reference, second.reference, axis]) + } + + fn invalid_object() -> CmdResult { + CmdResult::ReportError( + "Symmetric: select two compatible lines, circular curves, polyline segments, or ellipses." + .to_string(), + ) + } + + fn invalid_point() -> CmdResult { + CmdResult::ReportError( + "Symmetric: select an endpoint, center, midpoint, or polyline vertex.".to_string(), + ) + } + + fn invalid_axis() -> CmdResult { + CmdResult::ReportError("Symmetric: select a line as the symmetry axis.".to_string()) + } +} + +impl CadCommand for SymmetricConstraintCommand { + fn name(&self) -> &'static str { + "SYCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.step { + Step::ObjectOrTwoPoints => { + "Select first object or [2Points] <2Points>:".to_string() + } + Step::SecondObject(_) => "Select second object:".to_string(), + Step::FirstPoint => "Select first point:".to_string(), + Step::SecondPoint(_) => "Select second point:".to_string(), + Step::ObjectAxis(_, _) | Step::PointAxis(_, _) => { + "Select symmetry line:".to_string() + } + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::ObjectOrTwoPoints) + .then(|| vec![CmdOption::new("2Points", "2P")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::ObjectOrTwoPoints) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(self.step, Step::ObjectOrTwoPoints) + && matches!(keyword.as_str(), "2" | "2P" | "2POINT" | "2POINTS") + { + self.step = Step::FirstPoint; + Some(CmdResult::NeedPoint) + } else { + None + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_accepts_points(&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 self.on_point(point); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + match self.step { + Step::ObjectOrTwoPoints => { + let Some(first) = Self::picked_object(&entity, handle, point) else { + return Self::invalid_object(); + }; + self.step = Step::SecondObject(first); + CmdResult::NeedPoint + } + Step::SecondObject(first) => { + let Some(second) = Self::picked_object(&entity, handle, point) else { + return Self::invalid_object(); + }; + if first.family != second.family || first.reference == second.reference { + return Self::invalid_object(); + } + self.step = Step::ObjectAxis(first, second); + CmdResult::NeedPoint + } + Step::FirstPoint | Step::SecondPoint(_) => { + let world = acadrust::types::Vector3::new(point.x, point.y, point.z); + if !is_parametric_point_near(&entity, world) { + return Self::invalid_point(); + } + let picked = Self::point_pick(Some(handle), point); + match self.step { + Step::FirstPoint => { + self.step = Step::SecondPoint(picked); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => { + self.step = Step::PointAxis(first, picked); + CmdResult::NeedPoint + } + _ => unreachable!(), + } + } + Step::ObjectAxis(first, second) => { + let Some(axis) = Self::picked_axis(&entity, handle, point) else { + return Self::invalid_axis(); + }; + if axis.entity == first.reference.entity || axis.entity == second.reference.entity { + return Self::invalid_axis(); + } + CmdResult::AddSymmetricConstraint { + selection: SymmetricConstraintSelection::Objects( + first.reference, + second.reference, + ), + axis, + label: "Symmetric constraint", + } + } + Step::PointAxis(first, second) => { + let Some(axis) = Self::picked_axis(&entity, handle, point) else { + return Self::invalid_axis(); + }; + if first.handle == Some(axis.entity) || second.handle == Some(axis.entity) { + return Self::invalid_axis(); + } + CmdResult::AddSymmetricConstraint { + selection: SymmetricConstraintSelection::Points(first, second), + axis, + label: "Symmetric constraint", + } + } + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let picked = Self::point_pick(None, point); + match self.step { + Step::ObjectOrTwoPoints | Step::FirstPoint => { + self.step = Step::SecondPoint(picked); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => { + self.step = Step::PointAxis(first, picked); + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + if matches!(self.step, Step::ObjectOrTwoPoints) { + self.step = Step::FirstPoint; + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SYCONSTRAINT"] +}); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 98d983f5c..9cf34e21c 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -231,13 +231,19 @@ pub(crate) fn grip_solve_anchor_refs( } acadrust::EntityType::Arc(_) => match grip_id { 0 => vec![ParametricRef::center(handle)], - 1 => vec![ParametricRef::point(handle, 0)], - 2 => vec![ParametricRef::point(handle, 1)], + 1..=3 => vec![ParametricRef::whole(handle)], + _ => Vec::new(), + }, + acadrust::EntityType::Circle(_) => match grip_id { + 0 => vec![ParametricRef::center(handle)], + 1..=4 => vec![ParametricRef::whole(handle)], + _ => Vec::new(), + }, + acadrust::EntityType::Ellipse(_) => match grip_id { + 0 => vec![ParametricRef::center(handle)], + 1..=6 => vec![ParametricRef::whole(handle)], _ => Vec::new(), }, - acadrust::EntityType::Circle(_) if grip_id == 0 => { - vec![ParametricRef::center(handle)] - } acadrust::EntityType::Point(_) | acadrust::EntityType::Insert(_) | acadrust::EntityType::Text(_) @@ -304,11 +310,11 @@ pub enum ConstraintKind { /// For the supported entity types, equal curvature is already covered /// by the circle/circle radius branch of `Equal`. EqualDistance, - /// Two circles/arcs are mirror images of each other across a line — - /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the - /// circle-center-pair case (unambiguous with whole-entity selection); - /// point-symmetry about a point, and symmetry between two lines, aren't - /// modeled. + /// Two points or compatible curves are symmetric across a line. + /// Point mode stores two point refs plus the axis. Object mode stores two + /// whole/segment refs plus the axis and constrains the complete relevant + /// geometry: line direction, circular center/radius, or ellipse + /// center/axes. Symmetric, /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` @@ -951,6 +957,10 @@ fn glyph_placement_for_reference( let anchor = arc.midpoint_wcs(); Some((anchor, anchor - center)) } + (acadrust::EntityType::Ellipse(ellipse), None | Some(-3)) => { + let anchor = ellipse.center + ellipse.major_axis; + Some((anchor, ellipse.major_axis)) + } (acadrust::EntityType::Line(line), Some(marker)) => { let anchor = resolve_point(entity, marker)?; let direction = anchor - line_midpoint(line); @@ -983,7 +993,10 @@ fn glyph_placements( document: &acadrust::CadDocument, constraint: &ParametricConstraint, ) -> Vec<(Vector3, Vector3)> { - if constraint.kind == ConstraintKind::Parallel { + if matches!( + constraint.kind, + ConstraintKind::Parallel | ConstraintKind::Symmetric + ) { return constraint .refs .iter() @@ -1659,7 +1672,7 @@ impl super::Scene { }) .flat_map(|c| { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); - let label = if show_values { + let base_label = if show_values { glyph_label(c) } else { c.kind.glyph_symbol().to_string() @@ -1682,7 +1695,22 @@ impl super::Scene { .collect(); glyph_placements(&self.document, c) .into_iter() - .filter_map(|(anchor, outward)| { + .enumerate() + .filter_map(|(index, (anchor, outward))| { + let label = if c.kind == ConstraintKind::Symmetric { + if index == 2 { + "S│".to_string() + } else if c.refs.get(index).is_some_and(|reference| { + reference.marker.is_some() + && reference.segment_index().is_none() + }) { + "S•".to_string() + } else { + "S◇".to_string() + } + } else { + base_label.clone() + }; let screen = crate::scene::pick::grip::project_rte( glam::DVec3::new(anchor.x, anchor.y, anchor.z), view_rot, @@ -1706,7 +1734,7 @@ impl super::Scene { c.id, point, direction.to_array(), - label.clone(), + label, is_conflicting, hover_points.clone(), )) diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index fab85de33..af4321769 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -17,7 +17,7 @@ use cadkernel_constraints::constraints::curve_generic::{BoundedArcValue, CurveVa use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, - ProjectedDistance, ProjectedDistanceAlongLine, + ProjectedDistance, ProjectedDistanceAlongLine, SymmetricLineDirections, }; use cadkernel_constraints::constraints::Constraint; use cadkernel_constraints::geo::{ @@ -1363,18 +1363,86 @@ fn build_constraint( let [a, b, m] = c.refs.as_slice() else { return Vec::new(); }; - let (Some(pa), Some(pb), Some(mirror)) = ( - point_ref(sys, cache, *a), - point_ref(sys, cache, *b), - whole_line(sys, cache, *m), + let Some(mirror) = whole_line(sys, cache, *m) else { + return Vec::new(); + }; + if let (Some(pa), Some(pb)) = + (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + { + let pair = GLine { p1: pa, p2: pb }; + return vec![ + Rc::new(MidpointOnLine::new(pair, mirror)), + Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + ]; + } + let (Some(first), Some(second)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), ) else { return Vec::new(); }; - let pair = GLine { p1: pa, p2: pb }; - vec![ - Rc::new(MidpointOnLine::new(pair, mirror)), - Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), - ] + match ( + as_circle_or_line(first, *a), + as_circle_or_line(second, *b), + ) { + (CircleOrLine::Line(first), CircleOrLine::Line(second)) => { + vec![Rc::new(SymmetricLineDirections::new( + sys.store(), + first, + second, + mirror, + ))] + } + (CircleOrLine::Circle(first), CircleOrLine::Circle(second)) => { + let centers = GLine { + p1: first.center, + p2: second.center, + }; + vec![ + Rc::new(MidpointOnLine::new(centers, mirror)), + Rc::new(PerpendicularConstraint::new( + sys.store(), + centers, + mirror, + )), + Rc::new(Equal::new(second.rad, first.rad, 1.0)), + ] + } + (CircleOrLine::Ellipse(first), CircleOrLine::Ellipse(second)) => { + let centers = GLine { + p1: first.center, + p2: second.center, + }; + let first_axis = GLine { + p1: first.center, + p2: first.focus1, + }; + let second_axis = GLine { + p1: second.center, + p2: second.focus1, + }; + vec![ + Rc::new(MidpointOnLine::new(centers, mirror)), + Rc::new(PerpendicularConstraint::new( + sys.store(), + centers, + mirror, + )), + Rc::new(SymmetricLineDirections::new( + sys.store(), + first_axis, + second_axis, + mirror, + )), + Rc::new(EqualMajorAxesConic::new( + GConic::Ellipse(first), + GConic::Ellipse(second), + )), + Rc::new(Equal::new(second.radmin, first.radmin, 1.0)), + ] + } + _ => Vec::new(), + } } ConstraintKind::Fixed => { let Some(r) = c.refs.first() else { @@ -2309,10 +2377,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 { + // their initial placement. Temporary Fixed equations stay out of `owner` + // and the persistent native graph. Symmetric references are handled as + // exact driven anchors below, so they are excluded from this approximate + // equality-equation path. + let symmetric_constraints = constraints + .iter() + .filter(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && constraint.refs.len() == 3 + }) + .collect::>(); + let temporary_fixed_refs = initial_fixed_refs + .iter() + .copied() + .filter(|reference| { + !symmetric_constraints + .iter() + .any(|constraint| constraint.refs.contains(reference)) + }) + .collect::>(); + for reference in &temporary_fixed_refs { let temporary = ParametricConstraint { id: ConstraintId::MAX, kind: ConstraintKind::Fixed, @@ -2352,6 +2438,18 @@ fn solve_scope( } let mut anchors = driven_refs.to_vec(); + for constraint in &symmetric_constraints { + if let Some(axis) = constraint.refs.get(2).copied() { + if !anchors.contains(&axis) { + anchors.push(axis); + } + } + for reference in initial_fixed_refs { + if constraint.refs[..2].contains(reference) && !anchors.contains(reference) { + anchors.push(*reference); + } + } + } if !retain_lengths { let mut axis_lines: Vec<_> = line_axes.into_iter().filter_map(|(reference, vertical)| { let geometry = cache.get(&reference.entity)?; @@ -2478,6 +2576,9 @@ fn solve_scope( if initial_fixed_refs .iter() .any(|reference| reference.entity == *handle && reference.marker.is_none()) + || symmetric_constraints.iter().any(|constraint| { + constraint.refs.get(2).is_some_and(|axis| axis.entity == *handle) + }) { continue; } @@ -2638,6 +2739,18 @@ fn solve_scope( let EntityGeom::Ellipse(el) = geom else { continue; }; + let symmetric = set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && constraint.refs.get(..2).is_some_and(|references| { + references.iter().any(|reference| { + reference.entity == *handle && reference.marker.is_none() + }) + }) + }); + if symmetric { + continue; + } let (cx, cy, fx, fy) = { let store = sys.store(); (