From 059906f3475f89658768934c2eb04342ea9d8b96 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:18:59 +0300 Subject: [PATCH] Fix horizontal geometric constraint behavior --- src/app/command_driver.rs | 144 +++++++++++ src/app/commands/draw.rs | 53 +++- src/command.rs | 18 ++ src/modules/parametric/geom_constraint.rs | 2 +- src/modules/parametric/horizontal.rs | 284 ++++++++++++++++++++++ src/modules/parametric/mod.rs | 3 + src/modules/parametric/tools.rs | 3 +- src/scene/dwg_native_constraints.rs | 64 +++-- src/scene/parametric_constraints.rs | 66 +++++ src/scene/parametric_solve.rs | 161 +++++++----- 10 files changed, 710 insertions(+), 88 deletions(-) create mode 100644 src/modules/parametric/horizontal.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 178b41a0a..eb3fc889c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3188,6 +3188,150 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddHorizontalConstraint { + selection, + direction, + label, + } => { + use crate::command::HorizontalConstraintSelection; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, ConstraintKind, + DirectionalAxis, ParametricRef, + }; + + 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 (refs, initial_fixed) = match selection { + HorizontalConstraintSelection::Reference(reference) => { + let initial_fixed = match reference.directional_axis() { + Some(DirectionalAxis::EllipseMajor | DirectionalAxis::EllipseMinor) => { + vec![ParametricRef::center(reference.entity)] + } + Some(DirectionalAxis::TextBaseline) => { + vec![ParametricRef::point(reference.entity, 0)] + } + None => Vec::new(), + }; + (vec![reference], initial_fixed) + } + HorizontalConstraintSelection::Points(first, second) => { + let resolve = |pick: crate::command::CoincidentPick| { + if let Some(handle) = pick.handle { + nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + None, + ) + } + }; + let (Some(first_ref), Some(second_ref)) = + (resolve(first), resolve(second)) + else { + self.command_line.push_error( + "Horizontal: select supported endpoints, centers, midpoints, or vertices.", + ); + return Task::none(); + }; + if first_ref == second_ref { + self.command_line + .push_error("Horizontal: select two different points."); + return Task::none(); + } + (vec![first_ref, second_ref], vec![first_ref]) + } + }; + let axis_length = direction.x.hypot(direction.y); + if axis_length <= 1.0e-12 { + self.command_line + .push_error("Horizontal: the current UCS X axis is not supported."); + return Task::none(); + } + let direction = acadrust::types::Vector3::new( + direction.x / axis_length, + direction.y / axis_length, + 0.0, + ); + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Horizontal, + &refs, + None, + ) { + self.command_line.push_error(message); + return Task::none(); + } + if self + .tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.contains_axis_constraint( + ConstraintKind::Horizontal, + &refs, + direction, + ) + }) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("Horizontal constraint is already applied."); + 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_axis_constraint(ConstraintKind::Horizontal, refs, direction); + 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_parametric_policy( + &changes, + &initial_fixed, + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("Horizontal 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 4c747e0a1..1d2d954c0 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1318,7 +1318,50 @@ impl OpenCADStudio { } } - "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => { + "HCONSTRAINT" | "GCHORIZONTAL" => { + use crate::command::{CmdResult, HorizontalConstraintSelection}; + use crate::modules::parametric::HorizontalConstraintCommand; + + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + let command = HorizontalConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.len() != 1 { + self.command_line + .push_error("Horizontal: select exactly one compatible object."); + } else { + let handle = handles[0]; + let reference = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| { + HorizontalConstraintCommand::preselected_reference(entity, handle) + }); + if let Some(reference) = reference { + let direction = self.tabs[i].ucs_xform().working_plane().x; + return Some(self.apply_cmd_result(CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Reference(reference), + direction: acadrust::types::Vector3::new( + direction.x, + direction.y, + direction.z, + ), + label: "Horizontal constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Horizontal: select a line, straight polyline segment, text, MText, or an ellipse axis.", + ); + let command = HorizontalConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + } + + "VCONSTRAINT" | "FXCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1331,10 +1374,10 @@ impl OpenCADStudio { } else { use crate::command::CmdResult; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - let (kind, label) = match cmd { - "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), - "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), - _ => (ConstraintKind::Fixed, "Fixed constraint"), + let (kind, label) = if cmd == "VCONSTRAINT" { + (ConstraintKind::Vertical, "Vertical constraint") + } else { + (ConstraintKind::Fixed, "Fixed constraint") }; return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, diff --git a/src/command.rs b/src/command.rs index a39f6a171..ca1a6fb62 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1248,6 +1248,16 @@ pub struct CoincidentPick { pub whole_curve: bool, } +/// The two input forms accepted by the Horizontal geometric constraint. +/// Object picks are resolved while the entity snapshot is available; point +/// picks are resolved by the host against the live document so two points on +/// the same entity remain distinguishable. +#[derive(Clone, Copy, Debug)] +pub enum HorizontalConstraintSelection { + Reference(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 { @@ -1494,6 +1504,14 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, + /// Adds a Horizontal relation against the UCS X direction captured when + /// the command starts. The direction is persisted with the constraint so + /// later edits and save/reopen do not silently fall back to world X. + AddHorizontalConstraint { + selection: HorizontalConstraintSelection, + direction: acadrust::types::Vector3, + 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/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs index 199912c87..77be824d3 100644 --- a/src/modules/parametric/geom_constraint.rs +++ b/src/modules/parametric/geom_constraint.rs @@ -14,7 +14,7 @@ impl GeomConstraintCommand { fn dispatch(keyword: &str) -> Option<&'static str> { Some(match keyword { - "H" | "HORIZONTAL" => "HCONSTRAINT", + "H" | "HORIZONTAL" => "GCHORIZONTAL", "V" | "VERTICAL" => "VCONSTRAINT", "P" | "PERPENDICULAR" => "GCPERPENDICULAR", "PA" | "PARALLEL" => "PCONSTRAINT", diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs new file mode 100644 index 000000000..c7fcaab1a --- /dev/null +++ b/src/modules/parametric/horizontal.rs @@ -0,0 +1,284 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, CoincidentPick, HorizontalConstraintSelection, WorkingPlane, +}; +use crate::scene::parametric_constraints::{ + directional_axis_endpoints, is_parametric_point_near, ParametricRef, +}; + +#[derive(Clone, Copy)] +enum Step { + ObjectOrTwoPoints, + FirstPoint, + SecondPoint(CoincidentPick), +} + +/// Reference-compatible front end for the Horizontal geometric constraint. +/// It deliberately uses entity picks rather than the generic selection set so +/// a polyline segment or one ellipse axis can be addressed unambiguously. +pub struct HorizontalConstraintCommand { + step: Step, + picked_entity: Option, + plane: WorkingPlane, +} + +impl HorizontalConstraintCommand { + pub fn new() -> Self { + Self { + step: Step::ObjectOrTwoPoints, + picked_entity: None, + plane: WorkingPlane::default(), + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn direction(&self) -> acadrust::types::Vector3 { + let direction = self.plane.x.normalize_or(DVec3::X); + acadrust::types::Vector3::new(direction.x, direction.y, direction.z) + } + + fn invalid_object() -> CmdResult { + CmdResult::ReportError( + "Horizontal: select a line, straight polyline segment, text, MText, or an ellipse axis." + .to_string(), + ) + } + + fn invalid_point() -> CmdResult { + CmdResult::ReportError( + "Horizontal: select an endpoint, center, midpoint, or polyline vertex." + .to_string(), + ) + } + + fn segment_is_straight(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 distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 { + let start = DVec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z); + let end = DVec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z); + let direction = end - start; + if direction.length_squared() <= 1.0e-24 { + return f64::INFINITY; + } + (point - start).cross(direction.normalize()).length() + } + + fn ellipse_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let major = ParametricRef::ellipse_major_axis(handle); + let minor = ParametricRef::ellipse_minor_axis(handle); + let major_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); + let minor_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); + Some(if minor_distance < major_distance { minor } else { major }) + } + + fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + match entity { + EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => { + Some(ParametricRef::whole(handle)) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + Self::segment_is_straight(entity, index) + .then_some(ParametricRef::segment(handle, index)) + } + EntityType::Text(_) | EntityType::MText(_) => { + Some(ParametricRef::text_baseline(handle)) + } + EntityType::Ellipse(_) => Self::ellipse_reference(entity, handle, point), + _ => None, + } + } + + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => { + Some(ParametricRef::whole(handle)) + } + EntityType::Text(_) | EntityType::MText(_) => { + Some(ParametricRef::text_baseline(handle)) + } + EntityType::Ellipse(_) => Some(ParametricRef::ellipse_major_axis(handle)), + EntityType::LwPolyline(polyline) => { + let count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (count == 1 && Self::segment_is_straight(entity, 0)) + .then_some(ParametricRef::segment(handle, 0)) + } + EntityType::Polyline2D(polyline) => { + let count = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (count == 1 && Self::segment_is_straight(entity, 0)) + .then_some(ParametricRef::segment(handle, 0)) + } + _ => None, + } + } + + fn finish_points(&self, first: CoincidentPick, second: CoincidentPick) -> CmdResult { + CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Points(first, second), + direction: self.direction(), + label: "Horizontal constraint", + } + } +} + +impl CadCommand for HorizontalConstraintCommand { + fn name(&self) -> &'static str { + "GCHORIZONTAL" + } + + fn prompt(&self) -> String { + match self.step { + Step::ObjectOrTwoPoints => { + "GCHORIZONTAL Select an object or [2Points] <2Points>:".to_string() + } + Step::FirstPoint => "GCHORIZONTAL Select first point:".to_string(), + Step::SecondPoint(_) => "GCHORIZONTAL Select second point:".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 set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + 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(reference) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_object(); + }; + CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Reference(reference), + direction: self.direction(), + label: "Horizontal constraint", + } + } + 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 pick = Self::point_pick(Some(handle), point); + match self.step { + Step::FirstPoint => { + self.step = Step::SecondPoint(pick); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => self.finish_points(first, pick), + Step::ObjectOrTwoPoints => unreachable!(), + } + } + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let pick = Self::point_pick(None, point); + match self.step { + Step::ObjectOrTwoPoints | Step::FirstPoint => { + self.step = Step::SecondPoint(pick); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => self.finish_points(first, pick), + } + } + + 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: &["GCHORIZONTAL"] +}); diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 056c17686..b6961de44 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -5,6 +5,8 @@ mod coincident; mod equal_distance; mod geom_constraint; +#[path = "horizontal.rs"] +mod horizontal_command; #[path = "perpendicular.rs"] mod perpendicular_command; mod point_on_entity; @@ -15,6 +17,7 @@ mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; pub use geom_constraint::GeomConstraintCommand; +pub use horizontal_command::HorizontalConstraintCommand; pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPick}; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 49f211727..46d581128 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -11,7 +11,7 @@ pub mod horizontal { icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/horizontal.svg" )), - event: ModuleEvent::Command("HCONSTRAINT".to_string()), + event: ModuleEvent::Command("GCHORIZONTAL".to_string()), } } } @@ -161,6 +161,7 @@ pub mod symmetric { inventory::submit!(crate::command::CommandRegistration { names: &[ "HCONSTRAINT", + "GCHORIZONTAL", "VCONSTRAINT", "PCONSTRAINT", "QCONSTRAINT", diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index f4d755674..b83841878 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -56,8 +56,6 @@ struct GroupBuilder<'a> { /// Real entity handles that ended up with a geometry node — what /// `AssocGeomDependency` objects get created for, in first-touch order. referenced_entities: Vec, - horizontal_datum: Option, - vertical_datum: Option, } impl<'a> GroupBuilder<'a> { @@ -68,8 +66,6 @@ impl<'a> GroupBuilder<'a> { next_node_id: FIRST_NODE_ID, entities: FxHashMap::default(), referenced_entities: Vec::new(), - horizontal_datum: None, - vertical_datum: None, } } @@ -653,15 +649,7 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } - fn datum_line(&mut self, horizontal: bool) -> i32 { - let cached = if horizontal { - self.horizontal_datum - } else { - self.vertical_datum - }; - if let Some(node_id) = cached { - return node_id; - } + fn datum_line(&mut self, direction: Vector3) -> i32 { let node_id = self.alloc_node_id(); self.push_node( node_id, @@ -670,18 +658,13 @@ impl<'a> GroupBuilder<'a> { geometry_dependency: Handle::NULL, geometry_node_id: node_id, point: Vector3::ZERO, - direction: if horizontal { - Vector3::UNIT_X + direction: if direction.length_squared() > 1.0e-24 { + direction.normalize() } else { - Vector3::UNIT_Y + Vector3::UNIT_X }, }, ); - if horizontal { - self.horizontal_datum = Some(node_id); - } else { - self.vertical_datum = Some(node_id); - } node_id } @@ -917,7 +900,12 @@ fn constraint_node( .map(|reference| builder.ref_node(*reference)) .collect::>()?; let horizontal = constraint.kind == ConstraintKind::Horizontal; - let datum = builder.datum_line(horizontal); + let fallback = if horizontal { + Vector3::UNIT_X + } else { + Vector3::UNIT_Y + }; + let datum = builder.datum_line(constraint.axis_direction.unwrap_or(fallback)); let node_id = builder.alloc_node_id(); builder.push_node( node_id, @@ -2215,6 +2203,38 @@ pub(super) fn native_constraint_set( if let Some(constraint) = set.constraints.last_mut() { constraint.enabled = enabled; constraint.rigid_points = rigid_points; + if matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) { + let datum_id = match &node.data { + AssocConstraintNodeData::Parallel { + datum_line_index, .. + } => *datum_line_index, + _ => None, + }; + let local_direction = datum_id.and_then(|datum_id| { + group.nodes.iter().find_map(|datum| { + (datum.node_id == datum_id).then_some(&datum.data).and_then(|data| { + match data { + AssocConstraintNodeData::Line { direction, .. } => { + Some(*direction) + } + _ => None, + } + }) + }) + }); + if let Some(local) = local_direction { + let [_, axis_x, axis_y] = group.work_plane; + let normal = Vector3::new( + axis_x.y * axis_y.z - axis_x.z * axis_y.y, + axis_x.z * axis_y.x - axis_x.x * axis_y.z, + axis_x.x * axis_y.y - axis_x.y * axis_y.x, + ); + let world = axis_x * local.x + axis_y * local.y + normal * local.z; + if world.length_squared() > 1.0e-24 { + constraint.axis_direction = Some(world.normalize()); + } + } + } if let AssocConstraintNodeData::Distance { direction_type, distance, diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 467f8d779..3b463dcbe 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -373,6 +373,10 @@ pub struct ParametricConstraint { /// identifies the direction line for the line-relative modes. pub(crate) distance_direction_type: u8, pub(crate) distance_direction: Option, + /// World-space datum direction captured for Horizontal/Vertical. Native + /// files store the same vector on the connected constrained datum line. + /// `None` keeps legacy world-X/world-Y behavior. + pub(crate) axis_direction: Option, /// Which of the four directed sectors an angular constraint measures. pub(crate) angle_sector: u8, } @@ -460,11 +464,71 @@ impl ParametricConstraintSet { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }); id } + /// Appends a Horizontal/Vertical constraint tied to an explicit datum + /// direction rather than silently interpreting it in world coordinates. + pub fn add_axis_constraint( + &mut self, + kind: ConstraintKind, + refs: Vec, + direction: Vector3, + ) -> ConstraintId { + let fallback = if kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = if direction.length_squared() > 1.0e-24 { + direction.normalize() + } else { + fallback + }; + let id = self.add(kind, refs, None); + if let Some(constraint) = self.constraints.last_mut() { + constraint.axis_direction = Some(direction); + } + id + } + + pub fn contains_axis_constraint( + &self, + kind: ConstraintKind, + refs: &[ParametricRef], + direction: Vector3, + ) -> bool { + let fallback = if kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = if direction.length_squared() > 1.0e-24 { + direction.normalize() + } else { + fallback + }; + self.constraints.iter().any(|constraint| { + if !constraint.enabled || constraint.kind != kind { + return false; + } + let same_refs = constraint.refs == refs + || (refs.len() == 2 + && constraint.refs.len() == 2 + && constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0]); + if !same_refs { + return false; + } + let existing = constraint.axis_direction.unwrap_or(fallback); + existing.length_squared() > 1.0e-24 + && existing.normalize().dot(&direction).abs() >= 1.0 - 1.0e-10 + }) + } + /// Removes a constraint by id. Returns whether one was actually removed. pub fn remove(&mut self, id: ConstraintId) -> bool { let before = self.constraints.len(); @@ -1790,6 +1854,7 @@ mod tests { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let (anchor, direction) = @@ -1825,6 +1890,7 @@ mod tests { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let tangent = relation( diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 30e56a8fc..82786ff1c 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -901,6 +901,37 @@ fn build_constraint( let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { resolve_constraint_point(document, sys, cache, r) }; + let directional_line = + |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + let minor_axis = matches!( + r.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::EllipseMinor) + ); + let line = match geometry { + EntityGeom::TextLine(line) + if matches!( + r.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::TextBaseline) + ) => line, + EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line) + if r.marker.is_none() => line, + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?)?, + EntityGeom::Ellipse(ellipse) + if matches!( + r.directional_axis(), + Some( + super::parametric_constraints::DirectionalAxis::EllipseMajor + | super::parametric_constraints::DirectionalAxis::EllipseMinor + ) + ) => GLine { + p1: ellipse.center, + p2: ellipse.focus1, + }, + _ => return None, + }; + Some((line, minor_axis)) + }; let point_on_bounded_arc = |sys: &mut System, point: GPoint, arc: GArc| { let initial = BoundedArcValue::initial_parameter(sys.store(), point, arc); @@ -933,30 +964,49 @@ fn build_constraint( ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { point_pair_equal(sys, cache, &c.refs) } - ConstraintKind::Horizontal => match c.refs.as_slice() { - [r] => whole_line(sys, cache, *r) - .map(|line| { - vec![Rc::new(Equal::new(line.p1.y, line.p2.y, 1.0)) as Rc] - }) - .unwrap_or_default(), - [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { - (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.y, b.y, 1.0))], + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + let fallback = if c.kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = c.axis_direction.unwrap_or(fallback); + let length = direction.x.hypot(direction.y); + let (dx, dy) = if length > 1.0e-12 { + (direction.x / length, direction.y / length) + } else { + (fallback.x, fallback.y) + }; + let datum = GLine { + p1: GPoint::new(sys.add_param(0.0, true), sys.add_param(0.0, true)), + p2: GPoint::new(sys.add_param(dx, true), sys.add_param(dy, true)), + }; + match c.refs.as_slice() { + [reference] => directional_line(sys, cache, *reference) + .map(|(line, minor_axis)| { + if minor_axis { + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + line, + datum, + )) as Rc] + } else { + vec![Rc::new(ParallelConstraint::new(sys.store(), line, datum)) + as Rc] + } + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(ParallelConstraint::new( + sys.store(), + GLine { p1: a, p2: b }, + datum, + ))], + _ => Vec::new(), + }, _ => Vec::new(), - }, - _ => Vec::new(), - }, - ConstraintKind::Vertical => match c.refs.as_slice() { - [r] => whole_line(sys, cache, *r) - .map(|line| { - vec![Rc::new(Equal::new(line.p1.x, line.p2.x, 1.0)) as Rc] - }) - .unwrap_or_default(), - [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { - (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.x, b.x, 1.0))], - _ => Vec::new(), - }, - _ => Vec::new(), - }, + } + } ConstraintKind::Parallel => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -972,39 +1022,6 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - let directional_line = - |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { - let geometry = resolve_ref(document, sys, cache, r)?; - let minor_axis = matches!( - r.directional_axis(), - Some(super::parametric_constraints::DirectionalAxis::EllipseMinor) - ); - let line = match geometry { - EntityGeom::TextLine(line) - if matches!( - r.directional_axis(), - Some(super::parametric_constraints::DirectionalAxis::TextBaseline) - ) => line, - EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line) - if r.marker.is_none() => line, - EntityGeom::Polyline { .. } => { - geometry.line_segment(r.segment_index()?)? - } - EntityGeom::Ellipse(ellipse) - if matches!( - r.directional_axis(), - Some( - super::parametric_constraints::DirectionalAxis::EllipseMajor - | super::parametric_constraints::DirectionalAxis::EllipseMinor - ) - ) => GLine { - p1: ellipse.center, - p2: ellipse.focus1, - }, - _ => return None, - }; - Some((line, minor_axis)) - }; let (Some((fixed, fixed_minor)), Some((moving, moving_minor))) = (directional_line(sys, cache, *a), directional_line(sys, cache, *b)) else { @@ -1868,7 +1885,21 @@ fn constrained_line_axes(constraints: &[&ParametricConstraint]) -> HashMap 1.0e-12 { + let (x, y) = (direction.x.abs() / length, direction.y.abs() / length); + if x >= 1.0 - 1.0e-10 { + axes.insert(*reference, false); + } else if y >= 1.0 - 1.0e-10 { + axes.insert(*reference, true); + } + } } } } @@ -1976,6 +2007,7 @@ fn solve_scope( rigid_points: Vec::new(), distance_direction_type: distance_direction_type::UNDIRECTED, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; for constraint in build_constraint( @@ -2295,7 +2327,12 @@ fn solve_scope( }; let directional = set.constraints.iter().any(|constraint| { constraint.enabled - && constraint.kind == ConstraintKind::Perpendicular + && matches!( + constraint.kind, + ConstraintKind::Horizontal + | ConstraintKind::Vertical + | ConstraintKind::Perpendicular + ) && constraint.refs.iter().any(|reference| { reference.entity == *handle && reference.directional_axis().is_some() }) @@ -2336,7 +2373,12 @@ fn solve_scope( }; let directional = set.constraints.iter().any(|constraint| { constraint.enabled - && constraint.kind == ConstraintKind::Perpendicular + && matches!( + constraint.kind, + ConstraintKind::Horizontal + | ConstraintKind::Vertical + | ConstraintKind::Perpendicular + ) && constraint.refs.iter().any(|reference| { reference.entity == *handle && matches!( @@ -2930,6 +2972,7 @@ impl Scene { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let mut system = System::new();