From 26e458d6f7a9e826ab0584fef35768a6611da6c3 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:19:49 +0300 Subject: [PATCH] Match Vertical geometric constraint behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) VCONSTRAINT (and the new GCVERTICAL name) uses the axis constraint flow: "Select an object or [2Points] <2Points>:", 2Points mode with the reference's re-prompts and messages, and the same object types as Horizontal — line, polyline segment, text, MText, ellipse axis. 2) Object mode turns the object about its first vertex; a two-point relation across two objects slides the second object rigidly onto the axis. 3) Re-applying an axis constraint reports that the constraint already exists; Vertical follows the UCS Y direction. 4) The constraint bar draws Vertical as the reference's axis mark, with the point marker for a two-point relation. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 121 +++++++++++++++++++-------- src/app/commands/draw.rs | 47 +++++++---- src/command.rs | 9 +- src/modules/parametric/horizontal.rs | 95 ++++++++++++++++----- src/scene/parametric_constraints.rs | 17 +++- src/ui/overlay.rs | 57 ++++++++++++- 6 files changed, 271 insertions(+), 75 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6de0b3768..f9151e96a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3245,22 +3245,30 @@ impl OpenCADStudio { } } CmdResult::AddHorizontalConstraint { + kind, selection, direction, label, } => { - use crate::command::HorizontalConstraintSelection; + use crate::command::{EntityTransform, HorizontalConstraintSelection}; + use crate::modules::parametric::HorizontalConstraintCommand; use crate::scene::parametric_constraints::{ - nearest_parametric_point, nearest_parametric_point_on_entity, ConstraintKind, - DirectionalAxis, ParametricRef, + nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, + ConstraintKind, DirectionalAxis, ParametricRef, }; + let vertical = kind == ConstraintKind::Vertical; + let axis = if vertical { "Vertical" } else { "Horizontal" }; 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) => { + // The reference turns the object about its first + // vertex: a line's start, the picked polyline + // segment's first vertex, a text's insertion point, + // an ellipse's center. let initial_fixed = match reference.directional_axis() { Some(DirectionalAxis::EllipseMajor | DirectionalAxis::EllipseMinor) => { vec![ParametricRef::center(reference.entity)] @@ -3268,7 +3276,10 @@ impl OpenCADStudio { Some(DirectionalAxis::TextBaseline) => { vec![ParametricRef::point(reference.entity, 0)] } - None => Vec::new(), + None => vec![ParametricRef::point( + reference.entity, + reference.segment_index().map_or(0, |index| index as i32), + )], }; (vec![reference], initial_fixed) } @@ -3290,26 +3301,42 @@ impl OpenCADStudio { ) } }; - 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(); + // A miss asks for that point again, keeping a good + // first pick; the same point twice asks for another + // second point — the reference's own re-prompts. + let (first_ref, second_ref) = match (resolve(first), resolve(second)) { + (Some(first_ref), Some(second_ref)) if first_ref != second_ref => { + (first_ref, second_ref) + } + (first_ref, second_ref) => { + let (message, keep_first) = if first_ref.is_none() { + ("No valid constraint point found.", None) + } else if second_ref.is_none() { + ("No valid constraint point found.", Some(first)) + } else { + ( + "The object or point is already selected. Select a different object or constraint point.", + Some(first), + ) + }; + let command = HorizontalConstraintCommand::resume(kind, keep_first); + self.command_line.push_error(message); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.tabs[i].snap_result = None; + 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."); + self.command_line.push_error(&format!( + "{axis}: the current UCS {} axis is not supported.", + if vertical { "Y" } else { "X" } + )); return Task::none(); } let direction = acadrust::types::Vector3::new( @@ -3317,11 +3344,9 @@ impl OpenCADStudio { direction.y / axis_length, 0.0, ); - if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - ConstraintKind::Horizontal, - &refs, - None, - ) { + if let Err(message) = + self.tabs[i].scene.validate_parametric_constraint(kind, &refs, None) + { self.command_line.push_error(message); return Task::none(); } @@ -3329,18 +3354,12 @@ impl OpenCADStudio { .tabs[i] .scene .parametric_constraint_set(scope) - .is_some_and(|set| { - set.contains_axis_constraint( - ConstraintKind::Horizontal, - &refs, - direction, - ) - }) + .is_some_and(|set| set.contains_axis_constraint(kind, &refs, direction)) { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.command_line - .push_error("Horizontal constraint is already applied."); + .push_error("The constraint already exists on the selected objects."); return Task::none(); } let constraints_before = self.tabs[i] @@ -3360,10 +3379,43 @@ impl OpenCADStudio { self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); + // Two points on different objects: the reference slides the + // second object rigidly onto the axis through the first + // point instead of re-solving its shape, so move it first + // and let the constraint then hold what already fits. + if let [first_ref, second_ref] = refs.as_slice() { + if first_ref.entity != second_ref.entity { + let position = |reference: &ParametricRef| { + self.tabs[i] + .scene + .document + .get_entity(reference.entity) + .zip(reference.marker) + .and_then(|(entity, marker)| resolve_point(entity, marker)) + }; + if let (Some(first_point), Some(second_point)) = + (position(first_ref), position(second_ref)) + { + let normal = (-direction.y, direction.x); + let offset = (second_point.x - first_point.x) * normal.0 + + (second_point.y - first_point.y) * normal.1; + if offset.abs() > 1.0e-9 { + self.tabs[i].scene.transform_entities( + &[second_ref.entity], + &EntityTransform::Translate(glam::DVec3::new( + -offset * normal.0, + -offset * normal.1, + 0.0, + )), + ); + } + } + } + } let id = self.tabs[i] .scene .parametric_constraint_set_mut(scope) - .add_axis_constraint(ConstraintKind::Horizontal, refs, direction); + .add_axis_constraint(kind, refs, direction); self.tabs[i].scene.note_parametric_constraint_applied( scope, id, @@ -3382,7 +3434,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.command_line - .push_output("Horizontal constraint applied."); + .push_output(&format!("{axis} constraint applied.")); self.refresh_properties(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -8741,6 +8793,7 @@ mod parametric_constraint_undo_tests { let direction = acadrust::types::Vector3::new(3.0, 4.0, 0.0).normalize(); let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, selection: crate::command::HorizontalConstraintSelection::Reference( ParametricRef::whole(handle), ), @@ -8798,6 +8851,7 @@ mod parametric_constraint_undo_tests { }; let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, selection: crate::command::HorizontalConstraintSelection::Points( pick(first, glam::DVec3::ZERO), pick(second, glam::DVec3::new(8.0, 3.0, 0.0)), @@ -8827,6 +8881,7 @@ mod parametric_constraint_undo_tests { .add_entity(acadrust::EntityType::Ellipse(ellipse)); let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, selection: crate::command::HorizontalConstraintSelection::Reference( ParametricRef::ellipse_minor_axis(handle), ), diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 521dd44d4..48fb17b65 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1377,18 +1377,34 @@ impl OpenCADStudio { } } - "GCHORIZONTAL" => { + "GCHORIZONTAL" | "VCONSTRAINT" | "GCVERTICAL" => { use crate::command::{CmdResult, HorizontalConstraintSelection}; use crate::modules::parametric::HorizontalConstraintCommand; + use crate::scene::parametric_constraints::ConstraintKind; + // Vertical is Horizontal mirrored onto the working plane's Y + // axis: same picks, same 2Points flow, same reference types. + let vertical = cmd != "GCHORIZONTAL"; + let (kind, axis, label) = if vertical { + (ConstraintKind::Vertical, "Vertical", "Vertical constraint") + } else { + (ConstraintKind::Horizontal, "Horizontal", "Horizontal constraint") + }; + let new_command = || { + if vertical { + HorizontalConstraintCommand::vertical() + } else { + HorizontalConstraintCommand::new() + } + }; let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { - let command = HorizontalConstraintCommand::new(); + let command = new_command(); 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."); + .push_error(&format!("{axis}: select exactly one compatible object.")); } else { let handle = handles[0]; let reference = self.tabs[i] @@ -1399,28 +1415,30 @@ impl OpenCADStudio { HorizontalConstraintCommand::preselected_reference(entity, handle) }); if let Some(reference) = reference { - let direction = self.tabs[i].ucs_xform().working_plane().x; + let plane = self.tabs[i].ucs_xform().working_plane(); + let direction = if vertical { plane.y } else { plane.x }; return Some(self.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind, selection: HorizontalConstraintSelection::Reference(reference), direction: acadrust::types::Vector3::new( direction.x, direction.y, direction.z, ), - label: "Horizontal constraint", + label, })); } 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_error(&format!( + "Invalid selection for {axis}. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc." + )); + let command = new_command(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } } - "VCONSTRAINT" | "FXCONSTRAINT" => { + "FXCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1433,16 +1451,11 @@ impl OpenCADStudio { } else { use crate::command::CmdResult; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - let (kind, label) = if cmd == "VCONSTRAINT" { - (ConstraintKind::Vertical, "Vertical constraint") - } else { - (ConstraintKind::Fixed, "Fixed constraint") - }; return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind, + kind: ConstraintKind::Fixed, refs: vec![ParametricRef::whole(handles[0])], driving_param: None, - label, + label: "Fixed constraint", })); } } diff --git a/src/command.rs b/src/command.rs index 356048dd9..00fa6019b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1504,10 +1504,12 @@ 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. + /// Adds a Horizontal or Vertical relation (`kind`) against the UCS X or + /// Y 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 the world axis. AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind, selection: HorizontalConstraintSelection, direction: acadrust::types::Vector3, label: &'static str, @@ -2797,6 +2799,7 @@ mod constraint_registry_tests { "OCCONSTRAINT", "GCHORIZONTAL", "VCONSTRAINT", + "GCVERTICAL", "PCONSTRAINT", "QCONSTRAINT", "GCPERPENDICULAR", diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs index 2b1b9f4ba..987cccf48 100644 --- a/src/modules/parametric/horizontal.rs +++ b/src/modules/parametric/horizontal.rs @@ -5,7 +5,7 @@ use crate::command::{ CadCommand, CmdOption, CmdResult, CoincidentPick, HorizontalConstraintSelection, WorkingPlane, }; use crate::scene::parametric_constraints::{ - directional_axis_endpoints, is_parametric_point_near, ParametricRef, + directional_axis_endpoints, is_parametric_point_near, ConstraintKind, ParametricRef, }; #[derive(Clone, Copy)] @@ -15,8 +15,10 @@ enum Step { SecondPoint(CoincidentPick), } -/// Interactive front end for the Horizontal geometric constraint. +/// Interactive front end for the Horizontal and Vertical geometric +/// constraints — one flow, mirrored across the working plane's X or Y axis. pub struct HorizontalConstraintCommand { + kind: ConstraintKind, step: Step, picked_entity: Option, plane: WorkingPlane, @@ -24,13 +26,51 @@ pub struct HorizontalConstraintCommand { impl HorizontalConstraintCommand { pub fn new() -> Self { + Self::for_kind(ConstraintKind::Horizontal) + } + + pub fn vertical() -> Self { + Self::for_kind(ConstraintKind::Vertical) + } + + fn for_kind(kind: ConstraintKind) -> Self { Self { + kind, step: Step::ObjectOrTwoPoints, picked_entity: None, plane: WorkingPlane::default(), } } + /// Re-enter the 2Points flow after the host rejected a pick: back at + /// the first point, or at the second with `first` kept. + pub fn resume(kind: ConstraintKind, first: Option) -> Self { + let mut command = Self::for_kind(kind); + command.step = match first { + Some(first) => Step::SecondPoint(first), + None => Step::FirstPoint, + }; + command + } + + pub fn kind(&self) -> ConstraintKind { + self.kind + } + + fn axis(&self) -> &'static str { + match self.kind { + ConstraintKind::Vertical => "Vertical", + _ => "Horizontal", + } + } + + fn label(&self) -> &'static str { + match self.kind { + ConstraintKind::Vertical => "Vertical constraint", + _ => "Horizontal constraint", + } + } + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { CoincidentPick { handle, @@ -40,21 +80,22 @@ impl HorizontalConstraintCommand { } fn direction(&self) -> acadrust::types::Vector3 { - let direction = self.plane.x.normalize_or(DVec3::X); + let direction = match self.kind { + ConstraintKind::Vertical => self.plane.y.normalize_or(DVec3::Y), + _ => 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_object(&self) -> CmdResult { + CmdResult::ReportError(format!( + "Invalid selection for {}. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.", + self.axis() + )) } fn invalid_point() -> CmdResult { - CmdResult::ReportError( - "Horizontal: select an endpoint, center, midpoint, or polyline vertex.".to_string(), - ) + CmdResult::ReportError("No valid constraint point found.".to_string()) } fn segment_is_straight(entity: &EntityType, index: usize) -> bool { @@ -153,27 +194,40 @@ impl HorizontalConstraintCommand { } } - fn finish_points(&self, first: CoincidentPick, second: CoincidentPick) -> CmdResult { + fn finish_points(&mut self, first: CoincidentPick, second: CoincidentPick) -> CmdResult { + // The reference refuses the same pick twice and asks again for the + // second point (the host repeats this check on the resolved refs). + if first.handle == second.handle && (first.point - second.point).length() <= 1.0e-9 { + self.step = Step::SecondPoint(first); + return CmdResult::ReportError( + "The object or point is already selected. Select a different object or constraint point." + .to_string(), + ); + } CmdResult::AddHorizontalConstraint { + kind: self.kind, selection: HorizontalConstraintSelection::Points(first, second), direction: self.direction(), - label: "Horizontal constraint", + label: self.label(), } } } impl CadCommand for HorizontalConstraintCommand { fn name(&self) -> &'static str { - "GCHORIZONTAL" + match self.kind { + ConstraintKind::Vertical => "GCVERTICAL", + _ => "GCHORIZONTAL", + } } fn prompt(&self) -> String { match self.step { Step::ObjectOrTwoPoints => { - "GCHORIZONTAL Select an object or [2Points] <2Points>:".to_string() + format!("{} Select an object or [2Points] <2Points>:", self.name()) } - Step::FirstPoint => "GCHORIZONTAL Select first point:".to_string(), - Step::SecondPoint(_) => "GCHORIZONTAL Select second point:".to_string(), + Step::FirstPoint => format!("{} Select first point:", self.name()), + Step::SecondPoint(_) => format!("{} Select second point:", self.name()), } } @@ -237,12 +291,13 @@ impl CadCommand for HorizontalConstraintCommand { match self.step { Step::ObjectOrTwoPoints => { let Some(reference) = Self::picked_reference(&entity, handle, point) else { - return Self::invalid_object(); + return self.invalid_object(); }; CmdResult::AddHorizontalConstraint { + kind: self.kind, selection: HorizontalConstraintSelection::Reference(reference), direction: self.direction(), - label: "Horizontal constraint", + label: self.label(), } } Step::FirstPoint | Step::SecondPoint(_) => { @@ -339,5 +394,5 @@ mod tests { } inventory::submit!(crate::command::CommandRegistration { - names: &["GCHORIZONTAL"] + names: &["GCHORIZONTAL", "GCVERTICAL"] }); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 98d983f5c..f2c774657 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -863,6 +863,19 @@ impl ConstraintKind { } } +/// Vertical draws the reference bar's axis mark (`src/ui/overlay.rs`): the +/// plain symbol for an object, this variant — with the point marker — for a +/// two-point relation. +pub(crate) const VERTICAL_POINTS_GLYPH: &str = "│·"; + +pub(crate) fn vertical_glyph_label(constraint: &ParametricConstraint) -> &'static str { + if constraint.refs.len() == 2 { + VERTICAL_POINTS_GLYPH + } else { + ConstraintKind::Vertical.glyph_symbol() + } +} + /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). pub(crate) fn glyph_label(constraint: &ParametricConstraint) -> String { @@ -1659,7 +1672,9 @@ impl super::Scene { }) .flat_map(|c| { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); - let label = if show_values { + let label = if c.kind == ConstraintKind::Vertical { + vertical_glyph_label(c).to_string() + } else if show_values { glyph_label(c) } else { c.kind.glyph_symbol().to_string() diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 1aecaba9e..b15508833 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -34,11 +34,16 @@ fn is_compact_coincident_glyph(label: &str) -> bool { matches!(label, "≡" | "∈") } +/// Vertical's two axis-mark labels (`parametric_constraints::vertical_glyph_label`). +fn is_vertical_glyph(label: &str) -> bool { + label == "│" || label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH +} + fn constraint_glyph_size(label: &str) -> Size { if is_compact_coincident_glyph(label) { return Size::new(COINCIDENT_GLYPH_SIZE, COINCIDENT_GLYPH_SIZE); } - if label == "G²" { + if label == "G²" || is_vertical_glyph(label) { let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; return Size::new(side, side); } @@ -94,6 +99,49 @@ fn draw_concentric_constraint_glyph( frame.stroke(&canvas::Path::circle(center, 2.15), stroke); } +/// The reference bar's Vertical mark: a red datum line hatched on its left, +/// the constrained direction beside it, plus the orange point marker for a +/// two-point relation. +fn draw_vertical_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + text: Color, + point_marker: bool, +) { + let datum = Color::from_rgb8(214, 76, 76); + let datum_x = center.x - 2.5; + let stroke = canvas::Stroke::default().with_color(datum).with_width(1.5); + frame.stroke( + &canvas::Path::line( + Point::new(datum_x, center.y - 6.5), + Point::new(datum_x, center.y + 6.5), + ), + stroke.clone(), + ); + for step in 0..3 { + let y = center.y - 4.5 + step as f32 * 4.0; + frame.stroke( + &canvas::Path::line(Point::new(datum_x, y), Point::new(datum_x - 4.0, y + 3.5)), + stroke.clone(), + ); + } + frame.stroke( + &canvas::Path::line( + Point::new(center.x + 2.5, center.y - 6.0), + Point::new(center.x + 2.5, center.y + 6.0), + ), + canvas::Stroke::default().with_color(text).with_width(1.4), + ); + if point_marker { + frame.stroke( + &canvas::Path::rectangle(Point::new(center.x + 4.5, center.y - 2.3), Size::new(4.6, 4.6)), + canvas::Stroke::default() + .with_color(Color::from_rgb8(224, 130, 62)) + .with_width(1.2), + ); + } +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -2020,6 +2068,13 @@ impl canvas::Program for SelectionCanvas { draw_smooth_constraint_glyph(&mut frame, glyph_center, fg); } else if label == "◎" { draw_concentric_constraint_glyph(&mut frame, glyph_center, fg); + } else if is_vertical_glyph(label) { + draw_vertical_constraint_glyph( + &mut frame, + glyph_center, + fg, + label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH, + ); } else { frame.fill_text(canvas::Text { content: label.clone(),