Convert: turn associative dimensions into dimensional constraints

The command deleted the dimension and left a literal constraint behind. The
reference keeps the dimension, drives it with a new parameter and gathers
only associative dimensions, saying how many objects it dropped.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5 committed 2026-09-23 11:30:18 +03:00
1 parent 133bfddba9
commit 6d36356529
4 files changed
+296 -88

No files matched your search

+63 -9
View File
@@ -432,11 +432,62 @@ impl OpenCADStudio {
self.feed_command_consumed(input).0
}
/// Keeps only associative dimensions in a completed selection when the
/// active command asks for that, deselecting the rest and reporting how
/// many objects were dropped.
fn filter_associative_dimension_selection(&mut self, input: StepInput) -> StepInput {
let StepInput::SelectionComplete(handles) = input else {
return input;
};
let i = self.active_tab;
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.selection_keeps_associative_dimensions())
{
return StepInput::SelectionComplete(handles);
}
let (kept, dropped): (Vec<Handle>, Vec<Handle>) = handles.into_iter().partition(|handle| {
// A dimension that already drives a constraint is not one to
// convert either.
crate::scene::parametric_constraints::dynamic_dimension_constraint(
&self.tabs[i].scene.parametric_constraints,
*handle,
)
.is_none()
&& crate::scene::dimension_assoc::constraint_from_associative_dimension(
&self.tabs[i].scene.document,
*handle,
)
.is_some()
});
for handle in &dropped {
self.tabs[i].scene.deselect_entity(*handle);
}
if !dropped.is_empty() {
let count = dropped.len();
self.command_line.push_info(
if count == 1 {
crate::t!("1 was filtered out.")
} else {
crate::tf!("{count} were filtered out.")
}
.as_ref(),
);
self.refresh_properties();
}
StepInput::SelectionComplete(kept)
}
/// `feed_command`, also reporting whether the step actually took the input.
/// Only a `Text` token can come back unclaimed (`on_text_input` returning
/// `None`), which is what lets the caller read it as something else — a
/// typed distance, say — instead of guessing beforehand.
pub(super) fn feed_command_consumed(&mut self, input: StepInput) -> (Task<Message>, bool) {
// A command that takes associative dimensions only keeps just those:
// the rest leave the selection and the count of what went is reported,
// as the reference does while gathering.
let input = self.filter_associative_dimension_selection(input);
// Selection keywords (P / PREVIOUS, L / LAST) consume the token
// before it reaches the command (#426).
if let StepInput::Text(s) = &input {
@@ -6179,15 +6230,18 @@ impl OpenCADStudio {
CmdResult::Dispatch(cmd) => {
// End this interactive front-end, then run the assembled command
// through the normal dispatcher. Selection is left untouched.
// DIMCONSTRAINT's Radius option becomes its next default; the
// DCRADIUS command run on its own leaves the default alone.
if cmd == "DCRADIUS"
&& self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() == "DIMCONSTRAINT")
{
self.dim_constraint_last = "Radius";
// The option picked in DIMCONSTRAINT becomes its next default;
// the same command run on its own leaves the default alone.
let from_menu = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() == "DIMCONSTRAINT");
if from_menu {
match cmd.as_str() {
"DCRADIUS" => self.dim_constraint_last = "Radius",
"DCCONVERT" => self.dim_constraint_last = "Convert",
_ => {}
}
}
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
+191 -79
View File
@@ -1310,92 +1310,204 @@ impl OpenCADStudio {
}
}
// A pre-selection is not used: the reference always asks, and it
// gathers associative dimensions only.
"DCCONVERT" => {
self.dim_constraint_last = "Convert";
use crate::modules::draw::select::SelectObjectsCommand;
self.tabs[i].scene.deselect_all();
let sel = SelectObjectsCommand::associative_dimensions(
cmd,
"DCCONVERTAPPLY",
"Select associative dimensions to convert:",
);
self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel));
}
// The gathered dimensions become dimensional constraints: each one
// keeps its dimension, which turns into the constraint's dynamic
// (or annotational) dimension driven by a new parameter.
"DCCONVERTAPPLY" => {
use crate::scene::named_parameters::DrivingValue;
use crate::scene::parametric_constraints::{
dynamic_dimension_text, next_angular_parameter_name,
next_dimensional_parameter_name, next_radial_parameter_name,
ConstraintKind,
};
let scope = self.tabs[i].current_parametric_scope();
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 {
let scope = self.tabs[i].current_parametric_scope();
let conversions: Vec<_> = handles
.iter()
.copied()
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.filter_map(|handle| {
let (kind, refs, value) =
crate::scene::dimension_assoc::constraint_from_associative_dimension(
&self.tabs[i].scene.document,
handle,
)?;
self.tabs[i]
.scene
.validate_parametric_constraint(kind, &refs, Some(&value))
.ok()?;
Some((handle, kind, refs, value))
})
.collect();
if conversions.is_empty() {
self.command_line
.push_output("No supported associative dimensions were selected.");
return None;
let annotational = self.constraint_form_annotational;
let mut table = self.tabs[i].scene.named_parameters().clone();
let mut conversions = Vec::new();
let mut refused = 0usize;
for handle in handles {
if self.tabs[i].scene.is_layer_locked(handle) {
refused += 1;
continue;
}
let before = self.tabs[i]
.scene
.parametric_constraint_set(scope)
.cloned()
.unwrap_or_else(|| {
crate::scene::parametric_constraints::ParametricConstraintSet::new(
scope,
)
});
let mut touched: Vec<_> = conversions
.iter()
.flat_map(|(_, _, refs, _)| refs.iter().map(|reference| reference.entity))
.collect();
touched.sort_unstable();
touched.dedup();
let dimensions: Vec<_> = conversions
.iter()
.map(|(handle, _, _, _)| *handle)
.collect();
let pending = self.begin_undo(
i,
"Convert dimensions to constraints",
touched.len() + dimensions.len(),
false,
);
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, before);
for (_, kind, refs, value) in conversions {
let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add(
kind,
refs,
Some(value),
);
self.tabs[i].scene.note_parametric_constraint_applied(
scope,
id,
self.constraint_bar_display,
);
// A dimension already driving a constraint is not converted
// a second time.
if crate::scene::parametric_constraints::dynamic_dimension_constraint(
&self.tabs[i].scene.parametric_constraints,
handle,
)
.is_some()
{
refused += 1;
continue;
}
self.tabs[i].scene.erase_entities(&dimensions);
let changes: Vec<_> = touched
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
self.refresh_properties();
let Some((kind, refs, value)) =
crate::scene::dimension_assoc::constraint_from_associative_dimension(
&self.tabs[i].scene.document,
handle,
)
else {
refused += 1;
continue;
};
let DrivingValue::Literal(measured) = value else {
refused += 1;
continue;
};
if self.tabs[i]
.scene
.validate_parametric_constraint(kind, &refs, Some(&DrivingValue::Literal(measured)))
.is_err()
{
refused += 1;
continue;
}
// The parameter is named after the constraint it drives,
// as a constraint made by hand would be.
let name = match kind {
ConstraintKind::Radius => next_radial_parameter_name(&table, false),
ConstraintKind::Diameter => next_radial_parameter_name(&table, true),
ConstraintKind::Angle | ConstraintKind::Angle3Point => {
next_angular_parameter_name(&table)
}
_ => next_dimensional_parameter_name(&table),
};
let expression =
crate::scene::parametric_constraints::measured_expression(measured);
if table.set(&name, &expression).is_err() {
refused += 1;
continue;
}
conversions.push((handle, kind, refs, name, expression, measured));
}
if conversions.is_empty() {
self.command_line
.push_output("0 associative dimensions converted");
self.command_line.push_output(
format!("{} dimension(s) converted.", dimensions.len()).as_str(),
format!("{refused} associative dimension(s) could not be converted")
.as_str(),
);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
self.tabs[i].scene.deselect_all();
self.refresh_properties();
return 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<_> = conversions
.iter()
.flat_map(|(_, _, refs, _, _, _)| refs.iter().map(|r| r.entity))
.collect();
touched.sort_unstable();
touched.dedup();
// The constraints layer is made before the recording opens: a
// new layer inside it would cost the delta its entity undo.
if !annotational {
self.tabs[i].scene.ensure_dynamic_dimension_layer();
}
let pending = self.begin_undo(
i,
"Convert dimensions to constraints",
touched.len() + conversions.len(),
false,
);
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, constraints_before);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
let format = self.tabs[i].scene.constraint_name_format;
let converted = conversions.len();
for (handle, kind, refs, name, expression, measured) in conversions {
let angular =
matches!(kind, ConstraintKind::Angle | ConstraintKind::Angle3Point);
let decimals = angular.then(|| {
crate::scene::parametric_constraints::angle_decimals(
&self.tabs[i].scene.document,
None,
)
});
let text = dynamic_dimension_text(
&name,
measured,
format,
false,
Some(&expression),
annotational,
decimals,
);
if let Some(acadrust::EntityType::Dimension(mut dimension)) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
let before = self.tabs[i].scene.document.get_entity_arc(handle);
self.tabs[i].scene.record_undo_before(handle, before);
crate::entities::dimension::set_dimension_text_override(
dimension.base_mut(),
Some(text),
);
// The dynamic form lives on the reference's hidden
// constraints layer in the constraint grey; the
// annotational form stays where the dimension is.
if !annotational {
dimension.base_mut().common.layer =
crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER
.to_string();
dimension.base_mut().common.color = acadrust::types::Color::Rgb {
r: 103,
g: 109,
b: 118,
};
}
self.tabs[i]
.scene
.update_entity(acadrust::EntityType::Dimension(dimension));
}
let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
let id = set.add(kind, refs, Some(DrivingValue::Named(name)));
set.dimensions.insert(id, handle);
self.tabs[i].scene.note_parametric_constraint_applied(
scope,
id,
self.constraint_bar_display,
);
}
let changes: Vec<_> = touched
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].scene.refresh_hidden_dynamic_dimensions();
self.tabs[i].scene.refresh_dynamic_dimension_scales(true);
self.tabs[i].scene.deselect_all();
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line
.push_output(format!("{converted} associative dimensions converted").as_str());
self.command_line.push_output(
format!("{refused} associative dimension(s) could not be converted").as_str(),
);
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
+7
View File
@@ -2782,6 +2782,13 @@ pub trait CadCommand: Send {
false
}
/// The command takes associative dimensions only. The host drops every
/// other object from a completed selection, says how many it dropped, and
/// hands the command what is left.
fn selection_keeps_associative_dimensions(&self) -> bool {
false
}
/// Called after a selection action completes while `is_selection_gathering` is true.
/// `handles` is the full set of currently selected entities.
/// Return `Relaunch` to fire the pending command, or `NeedPoint` to keep gathering.
+35
View File
@@ -36,6 +36,11 @@ pub struct SelectObjectsCommand {
pick_crossing: bool,
/// Add the command-specific Settings keyword used by Auto Constrain.
auto_constrain_settings: bool,
/// A command-specific prompt replacing the generic "Select objects:".
prompt_text: Option<&'static str>,
/// The command works on associative dimensions only; the host drops
/// everything else from each completed selection.
associative_dimensions_only: bool,
}
impl SelectObjectsCommand {
@@ -50,6 +55,8 @@ impl SelectObjectsCommand {
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
prompt_text: None,
associative_dimensions_only: false,
}
}
@@ -65,6 +72,8 @@ impl SelectObjectsCommand {
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
prompt_text: None,
associative_dimensions_only: false,
}
}
@@ -80,6 +89,8 @@ impl SelectObjectsCommand {
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
prompt_text: None,
associative_dimensions_only: false,
}
}
@@ -95,9 +106,24 @@ impl SelectObjectsCommand {
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
prompt_text: None,
associative_dimensions_only: false,
}
}
/// Gather associative dimensions with the command's own wording; the host
/// drops every other object from each completed selection.
pub fn associative_dimensions(
prompt_cmd: &str,
pending_cmd: &str,
prompt_text: &'static str,
) -> Self {
let mut command = Self::plain(prompt_cmd, pending_cmd);
command.prompt_text = Some(prompt_text);
command.associative_dimensions_only = true;
command
}
pub fn auto_constrain(pending_cmd: &str) -> Self {
let mut command = Self::new(pending_cmd);
command.auto_constrain_settings = true;
@@ -111,6 +137,11 @@ impl CadCommand for SelectObjectsCommand {
}
fn prompt(&self) -> String {
// A command with its own wording keeps it whatever is selected, the
// way the reference repeats its prompt after every selection.
if let (Some(text), None) = (self.prompt_text, self.pick.as_ref()) {
return format!("{} {}", self.prompt_cmd, t!(text));
}
if let Some(pick) = &self.pick {
let label = if !pick.closed {
t!("Fence")
@@ -161,6 +192,10 @@ impl CadCommand for SelectObjectsCommand {
self.pick.is_none()
}
fn selection_keeps_associative_dimensions(&self) -> bool {
self.associative_dimensions_only
}
// Clickable selection keywords (#426, #596). Window and Crossing fix the
// sense of the next box, which dragging would otherwise take from the
// direction the corner travels; All takes the whole space; Add and Remove