diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 5b425fae8..b851fe181 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3319,22 +3319,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)] @@ -3342,7 +3350,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) } @@ -3364,26 +3375,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( @@ -3391,11 +3418,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(); } @@ -3403,18 +3428,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] @@ -3434,10 +3453,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, @@ -3456,7 +3508,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); @@ -8941,6 +8993,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), ), @@ -8998,6 +9051,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)), @@ -9027,6 +9081,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 7d09dcc7e..ff18bb05c 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,49 +1415,29 @@ 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" => { - 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() != 1 { - self.command_line - .push_output("Select exactly one entity, then run this constraint again."); - } else { - use crate::command::CmdResult; - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Vertical, - refs: vec![ParametricRef::whole(handles[0])], - driving_param: None, - label: "Vertical constraint", - })); - } - } - "FXCONSTRAINT" | "GCFIX" => { use crate::command::CmdResult; use crate::modules::parametric::FixConstraintCommand; diff --git a/src/command.rs b/src/command.rs index 61118e79d..5b3159445 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1582,10 +1582,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, @@ -2887,6 +2889,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 6373eb077..e599ceb4f 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -914,6 +914,19 @@ pub(crate) fn fixed_glyph_label(constraint: &ParametricConstraint) -> &'static s } } +/// 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 { @@ -1719,6 +1732,8 @@ impl super::Scene { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); let base_label = if c.kind == ConstraintKind::Fixed { fixed_glyph_label(c).to_string() + } else if c.kind == ConstraintKind::Vertical { + vertical_glyph_label(c).to_string() } else if show_values { glyph_label(c) } else { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 3a2712ce5..eba42529d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -39,11 +39,16 @@ fn is_fixed_glyph(label: &str) -> bool { label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH } +/// 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²" || is_fixed_glyph(label) { + if label == "G²" || is_fixed_glyph(label) || is_vertical_glyph(label) { let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; return Size::new(side, side); } @@ -157,6 +162,49 @@ fn draw_fixed_constraint_glyph( } } +/// 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], @@ -2091,6 +2139,13 @@ impl canvas::Program for SelectionCanvas { bg, label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH, ); + } 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(),