diff --git a/assets/icons/constrain/fixed.svg b/assets/icons/constrain/fixed.svg
index b0e75becb..dd37ea457 100644
--- a/assets/icons/constrain/fixed.svg
+++ b/assets/icons/constrain/fixed.svg
@@ -1,7 +1,5 @@
diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs
index 6de0b3768..7bb0f090e 100644
--- a/src/app/command_driver.rs
+++ b/src/app/command_driver.rs
@@ -3177,23 +3177,23 @@ impl OpenCADStudio {
driving_param,
label,
} => {
+ use crate::scene::parametric_constraints::ConstraintKind;
+ // GCSMOOTH and FXCONSTRAINT re-prompt after a rejected pick.
+ let keep_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| {
+ (kind == ConstraintKind::Smooth && command.name() == "GCSMOOTH")
+ || (kind == ConstraintKind::Fixed && command.name() == "FXCONSTRAINT")
+ });
if let Err(message) = self.tabs[i].scene.validate_parametric_constraint(
kind,
&refs,
driving_param.as_ref(),
) {
- let keep_smooth_selection = kind
- == crate::scene::parametric_constraints::ConstraintKind::Smooth
- && self.tabs[i]
- .active_cmd
- .as_ref()
- .is_some_and(|command| command.name() == "GCSMOOTH");
- if !keep_smooth_selection {
+ if !keep_command {
self.tabs[i].active_cmd = None;
}
self.tabs[i].snap_result = None;
self.command_line.push_error(message);
- if keep_smooth_selection {
+ if keep_command {
if let Some(prompt) =
self.tabs[i].active_cmd.as_ref().map(|command| command.prompt())
{
@@ -3203,6 +3203,22 @@ impl OpenCADStudio {
return Task::none();
}
let scope = self.tabs[i].current_parametric_scope();
+ if kind == ConstraintKind::Fixed
+ && self.tabs[i]
+ .scene
+ .parametric_constraint_set(scope)
+ .is_some_and(|set| {
+ set.constraints.iter().any(|existing| {
+ existing.enabled && existing.kind == kind && existing.refs == refs
+ })
+ })
+ {
+ self.tabs[i].active_cmd = None;
+ self.tabs[i].snap_result = None;
+ self.command_line
+ .push_error("The constraint already exists on the selected objects.");
+ return Task::none();
+ }
let constraints_before = self.tabs[i]
.scene
.parametric_constraint_set(scope)
@@ -3244,6 +3260,59 @@ impl OpenCADStudio {
self.commit_undo_delta(i, pd);
}
}
+ CmdResult::AddFixedConstraint(pick) => {
+ use crate::modules::parametric::FixConstraintCommand;
+ use crate::scene::parametric_constraints::{
+ nearest_parametric_point, nearest_parametric_point_on_entity,
+ parametric_curve_ref_for_pick, ConstraintKind,
+ };
+
+ let scope = self.tabs[i].current_parametric_scope();
+ let document = &self.tabs[i].scene.document;
+ let world =
+ acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z);
+ let resolved = match (pick.whole_curve, pick.handle) {
+ (true, Some(handle)) => {
+ parametric_curve_ref_for_pick(document, scope, handle, world)
+ .ok_or(FixConstraintCommand::INVALID_OBJECT)
+ }
+ (true, None) => Err(FixConstraintCommand::NO_OBJECT),
+ (false, Some(handle)) => {
+ nearest_parametric_point_on_entity(document, scope, handle, world)
+ .ok_or(FixConstraintCommand::INVALID_OBJECT)
+ }
+ (false, None) => nearest_parametric_point(document, scope, world, None)
+ .ok_or(FixConstraintCommand::NO_POINT),
+ };
+ // An off-plane or 3D curve is "not a valid object" to the
+ // reference, not a solver limitation.
+ let resolved = resolved.and_then(|reference| {
+ let supported = !pick.whole_curve
+ || self.tabs[i]
+ .scene
+ .validate_parametric_constraint(
+ ConstraintKind::Fixed,
+ &[reference],
+ None,
+ )
+ .is_ok();
+ supported
+ .then_some(reference)
+ .ok_or(FixConstraintCommand::INVALID_OBJECT)
+ });
+ return match resolved {
+ Ok(reference) => self.apply_cmd_result(CmdResult::AddParametricConstraint {
+ kind: ConstraintKind::Fixed,
+ refs: vec![reference],
+ driving_param: None,
+ label: "Fixed constraint",
+ }),
+ // A miss re-prompts: `ReportError` keeps the command.
+ Err(message) => {
+ self.apply_cmd_result(CmdResult::ReportError(message.to_string()))
+ }
+ };
+ }
CmdResult::AddHorizontalConstraint {
selection,
direction,
diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs
index 521dd44d4..abc860fbb 100644
--- a/src/app/commands/draw.rs
+++ b/src/app/commands/draw.rs
@@ -1420,7 +1420,7 @@ impl OpenCADStudio {
}
}
- "VCONSTRAINT" | "FXCONSTRAINT" => {
+ "VCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -1433,20 +1433,49 @@ 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::Vertical,
refs: vec![ParametricRef::whole(handles[0])],
driving_param: None,
- label,
+ label: "Vertical constraint",
}));
}
}
+ "FXCONSTRAINT" | "GCFIX" => {
+ use crate::command::CmdResult;
+ use crate::modules::parametric::FixConstraintCommand;
+ use crate::scene::parametric_constraints::ConstraintKind;
+
+ // One preselected whole curve applies at once (ribbon
+ // "select first, then click"); anything else goes through
+ // the reference's own point-or-object prompt.
+ let handles = self.tabs[i].scene.selected_handles_in_order();
+ if let [handle] = handles.as_slice() {
+ let reference = self.tabs[i]
+ .scene
+ .document
+ .get_entity(*handle)
+ .and_then(|entity| {
+ FixConstraintCommand::preselected_reference(entity, *handle)
+ });
+ if let Some(reference) = reference {
+ return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
+ kind: ConstraintKind::Fixed,
+ refs: vec![reference],
+ driving_param: None,
+ label: "Fixed constraint",
+ }));
+ }
+ }
+ if !handles.is_empty() {
+ self.tabs[i].scene.deselect_all();
+ }
+ let command = FixConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
+ }
+
"CCONSTRAINT" | "GCCOINCIDENT" => {
use crate::modules::parametric::CoincidentConstraintCommand;
let new_cmd = CoincidentConstraintCommand::new();
diff --git a/src/command.rs b/src/command.rs
index 356048dd9..79eb0f5d8 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -1570,6 +1570,11 @@ pub enum CmdResult {
kind: crate::scene::parametric_constraints::ConstraintKind,
label: &'static str,
},
+ /// Add a persistent `Fixed` constraint (`crate::modules::parametric::fixed`)
+ /// at one picked constraint point (`whole_curve == false`, resolved by the
+ /// host like `AddPointOnEntityConstraint`'s point) or on the whole curve /
+ /// polyline segment under the pick (`whole_curve == true`).
+ AddFixedConstraint(CoincidentPick),
/// Add a persistent `EqualDistance` constraint
/// (`crate::modules::parametric::equal_distance`): the distance
/// between `points[0]`/`points[1]` equals the distance between
@@ -2806,6 +2811,7 @@ mod constraint_registry_tests {
"NRCONSTRAINT",
"LCONSTRAINT",
"FXCONSTRAINT",
+ "GCFIX",
"SYCONSTRAINT",
"DCONSTRAINT",
"ACONSTRAINT",
diff --git a/src/modules/parametric/fixed.rs b/src/modules/parametric/fixed.rs
new file mode 100644
index 000000000..c4c641500
--- /dev/null
+++ b/src/modules/parametric/fixed.rs
@@ -0,0 +1,139 @@
+//! GCFIX / FXCONSTRAINT — locks a constraint point, or a whole curve /
+//! polyline segment, at its current position. Picks resolve on the host (a
+//! `CadCommand` has no document access): `AddFixedConstraint` carries the
+//! click, the driver maps it to a `ParametricRef` and re-prompts on a miss,
+//! the way the reference does.
+
+use acadrust::{EntityType, Handle};
+use glam::DVec3;
+
+use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick};
+use crate::scene::parametric_constraints::ParametricRef;
+
+#[derive(Clone, Copy)]
+enum Step {
+ PointOrObject,
+ Object,
+}
+
+pub struct FixConstraintCommand {
+ step: Step,
+}
+
+impl FixConstraintCommand {
+ pub const INVALID_OBJECT: &'static str =
+ "Invalid selection for Fixed. Select a line, polyline segment, circle, arc, ellipse or spline.";
+ pub const NO_POINT: &'static str = "No valid constraint point found.";
+ pub const NO_OBJECT: &'static str = "No object found.";
+
+ pub fn new() -> Self {
+ Self {
+ step: Step::PointOrObject,
+ }
+ }
+
+ /// The Object-mode reading of a single preselected entity: a whole
+ /// curve, or a one-segment polyline's segment. Anything else prompts.
+ pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option {
+ match entity {
+ EntityType::Line(_)
+ | EntityType::Circle(_)
+ | EntityType::Arc(_)
+ | EntityType::Ellipse(_)
+ | EntityType::Spline(_) => Some(ParametricRef::whole(handle)),
+ EntityType::LwPolyline(polyline) => (polyline.vertices.len() == 2
+ && !polyline.is_closed)
+ .then(|| ParametricRef::segment(handle, 0)),
+ EntityType::Polyline2D(polyline) => (polyline.vertices.len() == 2
+ && !polyline.is_closed())
+ .then(|| ParametricRef::segment(handle, 0)),
+ _ => None,
+ }
+ }
+
+ fn pick(&self, handle: Option, point: DVec3) -> CmdResult {
+ CmdResult::AddFixedConstraint(CoincidentPick {
+ handle,
+ point,
+ whole_curve: matches!(self.step, Step::Object),
+ })
+ }
+}
+
+impl CadCommand for FixConstraintCommand {
+ fn name(&self) -> &'static str {
+ "FXCONSTRAINT"
+ }
+
+ fn prompt(&self) -> String {
+ match self.step {
+ Step::PointOrObject => "FXCONSTRAINT Select point or [Object]