Merge pull request #1357 from ramox81/codex/vertical-constraint-parity

Match Vertical geometric constraint behavior

# Conflicts:
#	src/app/commands/draw.rs
#	src/scene/parametric_constraints.rs
#	src/ui/overlay.rs
This commit is contained in:
Hakan Seven committed 2026-09-18 18:54:12 +03:00
commit 3ba7b38934
6 files changed
+267 -88

No files matched your search

+88 -33
View File
@@ -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),
),
+27 -31
View File
@@ -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;
+6 -3
View File
@@ -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",
+75 -20
View File
@@ -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<EntityType>,
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<CoincidentPick>) -> 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<Handle>, 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"]
});
+15
View File
@@ -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 {
+56 -1
View File
@@ -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<Message> 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(),