From 72fd3fd35b42cb24384f68dca52622236c2f330b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:12:46 +0300 Subject: [PATCH 1/5] Linear dimensional constraints: point flow, dynamic dimension, driving parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DCLINEAR, DCHORIZONTAL, DCVERTICAL and DCALIGNED follow the reference flow: `Specify first constraint point or [Object] :`, `Specify second constraint point:`, `Specify dimension line location:`, then `Dimension text = …` and `Enter value or name and value :`. Linear reads horizontal or vertical from the dimension line location the way a linear dimension does; Object takes a line's, an arc's or a polyline segment's ends. A miss reports `No valid constraint point found.` / `No object found.` and asks again; another object type reports `Invalid selection for Linear. Select a line, polyline segment or arc.`. Each constraint creates a drawing parameter (`d1`, `d2`, …, or the typed `name=expression`), drives the constraint through it, and draws a dynamic dimension in constraint gray whose text follows CONSTRAINTNAMEFORMAT (`d1=100.0000`); the dimension is associated with the constrained objects and its text is refreshed after every solve. A typed value other than the measured one moves the second point while the first stays, and so does a later parameter edit. Deleting the constraint removes its dimension and, when nothing else reads it, its parameter. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 248 ++++++++++++- src/app/commands/draw.rs | 42 ++- src/command.rs | 29 ++ src/modules/annotate/aligned_dim.rs | 40 +- src/modules/annotate/linear_dim.rs | 42 ++- src/modules/parametric/dim_constraint.rs | 445 +++++++++++++++++++++++ src/modules/parametric/mod.rs | 2 + src/scene/mod.rs | 5 + src/scene/parametric_constraints.rs | 81 ++++- 9 files changed, 898 insertions(+), 36 deletions(-) create mode 100644 src/modules/parametric/dim_constraint.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0e21a8e6e..6a3584ab0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1103,6 +1103,50 @@ impl OpenCADStudio { TableCellEditStart::Started } + /// Reports a refused command-line value and shows the prompt again. + fn reprompt_active_command(&mut self, i: usize, message: &str) { + self.command_line.push_error(message); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { + self.command_line.push_info(&prompt); + } + } + + /// Drops what removed dimensional constraints leave behind: their dynamic + /// dimensions, and their parameters when no other constraint reads them. + pub(crate) fn purge_dimensional_extras( + &mut self, + i: usize, + dimensions: Vec, + parameters: Vec, + ) { + if !dimensions.is_empty() { + self.tabs[i].scene.erase_entities(&dimensions); + } + let unused: Vec = parameters + .into_iter() + .filter(|name| { + !self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .any(|constraint| { + matches!( + &constraint.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(used)) + if used == name + ) + }) + }) + .collect(); + if !unused.is_empty() { + self.tabs[i].scene.record_undo_named_parameters_before(); + for name in &unused { + self.tabs[i].scene.named_parameters_mut().remove(name); + } + } + } + fn remove_parametric_constraint( &mut self, id: crate::scene::parametric_constraints::ConstraintId, @@ -1117,8 +1161,13 @@ impl OpenCADStudio { return false; }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); + let dimension = set.dimensions.get(&id).copied(); + let parameter = match &constraint.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => Some(name.clone()), + _ => None, + }; let constraints_before = set.clone(); - let pending = self.begin_undo(i, label, touched.len(), true); + let pending = self.begin_undo(i, label, touched.len(), dimension.is_none()); self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); @@ -1126,6 +1175,11 @@ impl OpenCADStudio { .scene .parametric_constraint_set_mut(scope) .remove(id); + self.purge_dimensional_extras( + i, + dimension.into_iter().collect(), + parameter.into_iter().collect(), + ); if self.tabs[i].scene.selected_constraint == Some(id) { self.tabs[i].scene.selected_constraint = None; } @@ -1219,6 +1273,23 @@ impl OpenCADStudio { .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); + // A changed value moves a dimensional constraint's second point; its + // first point stays, as in the reference. + let anchors: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!( + c.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(_)) + ) + }) + .filter_map(|c| c.refs.first().copied()) + .filter(|reference| reference.marker.is_some()) + .collect(); let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); self.tabs[i].scene.record_undo_named_parameters_before(); self.tabs[i].scene.named_parameters = table; @@ -1228,10 +1299,15 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &anchors, + self.constraint_solve_mode, + ); } else { self.tabs[i].scene.sync_native_parametric_graph(); } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } @@ -3450,6 +3526,174 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CheckConstraintPoint(pick) => { + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, + }; + let scope = self.tabs[i].current_parametric_scope(); + let resolved = { + let document = &self.tabs[i].scene.document; + let world = + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let reference = match pick.handle { + Some(handle) => { + nearest_parametric_point_on_entity(document, scope, handle, world) + } + None => nearest_parametric_point(document, scope, world, None), + }; + reference.map(|reference| { + let point = document + .get_entity(reference.entity) + .zip(reference.marker) + .and_then(|(entity, marker)| resolve_point(entity, marker)) + .map_or(pick.point, |p| glam::DVec3::new(p.x, p.y, p.z)); + (reference, point) + }) + }; + let Some((reference, point)) = resolved else { + return self.apply_cmd_result(CmdResult::ReportError( + crate::modules::parametric::DimConstraintCommand::NO_POINT.to_string(), + )); + }; + let result = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.accept_constraint_point(reference, point)); + return match result { + Some(result) => self.apply_cmd_result(result), + None => Task::none(), + }; + } + CmdResult::AddDimensionalConstraint { + kind, + first, + second, + first_point, + second_point, + location, + axis, + name, + expression, + label, + } => { + use crate::modules::annotate::{aligned_dim, linear_dim}; + use crate::scene::named_parameters::{is_valid_name, DrivingValue}; + use crate::scene::parametric_constraints::{dynamic_dimension_text, ConstraintKind}; + + let scope = self.tabs[i].current_parametric_scope(); + let refs = vec![first, second]; + // A refused value keeps the prompt open, as the reference's + // editor does. + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + Some(&DrivingValue::Literal(1.0)), + ) { + self.reprompt_active_command(i, message); + return Task::none(); + } + let name = name.trim().to_string(); + if !is_valid_name(&name) { + self.reprompt_active_command(i, "Invalid parameter name."); + return Task::none(); + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&name, &expression) { + self.reprompt_active_command(i, &error.to_string()); + return Task::none(); + } + let value = match table.resolve(&name) { + Ok(value) if value.is_finite() => value, + _ => { + self.reprompt_active_command(i, "Invalid expression."); + return Task::none(); + } + }; + let text = dynamic_dimension_text( + &name, + value, + self.tabs[i].scene.constraint_name_format, + ); + let mut entity = if kind == ConstraintKind::Distance { + aligned_dim::aligned_dimension_entity( + first_point, + second_point, + location, + Some(text), + ) + } else { + linear_dim::linear_dimension_entity( + first_point, + second_point, + location, + axis, + Some(text), + ) + }; + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" { + entity.as_entity_mut().set_layer(layer); + } + // A dynamic dimension draws in the constraint gray, not the + // current color. + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + 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![first.entity]; + if second.entity != first.entity { + touched.push(second.entity); + } + let pending = self.begin_undo(i, label, touched.len() + 1, 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 dimension = self.tabs[i].scene.add_entity(entity); + 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, dimension); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + self.tabs[i].scene.attach_dimension_association( + dimension, + vec![Some(first.entity), Some(second.entity)], + ); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + // The first point stays; a value other than the measured one + // moves the second, as in the reference. + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[first], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::AddFixedConstraint(pick) => { use crate::modules::parametric::FixConstraintCommand; use crate::scene::parametric_constraints::{ diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 582cbfb41..79d66aa27 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1131,7 +1131,26 @@ impl OpenCADStudio { self.command_line.push_output("No constraints found."); return None; } - let pending = self.begin_undo(i, "Delete constraints", handles.len(), true); + let dimensions: Vec = ids + .iter() + .filter_map(|id| before.dimensions.get(id).copied()) + .collect(); + let parameters: Vec = ids + .iter() + .filter_map(|id| before.get(*id)) + .filter_map(|constraint| match &constraint.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => { + Some(name.clone()) + } + _ => None, + }) + .collect(); + let pending = self.begin_undo( + i, + "Delete constraints", + handles.len(), + dimensions.is_empty(), + ); self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, before); @@ -1139,6 +1158,7 @@ impl OpenCADStudio { for id in &ids { set.remove(*id); } + self.purge_dimensional_extras(i, dimensions, parameters); let changes: Vec<_> = handles .iter() .copied() @@ -1726,8 +1746,24 @@ impl OpenCADStudio { } } - "DCONSTRAINT" | "DCLINEAR" | "DCHORIZONTAL" | "DCVERTICAL" | "DCALIGNED" - | "DCRADIUS" | "DCDIAMETER" => { + "DCLINEAR" | "DCHORIZONTAL" | "DCVERTICAL" | "DCALIGNED" => { + use crate::modules::parametric::{DimConstraintAxis, DimConstraintCommand}; + use crate::scene::parametric_constraints::next_dimensional_parameter_name; + let axis = match cmd { + "DCHORIZONTAL" => DimConstraintAxis::Horizontal, + "DCVERTICAL" => DimConstraintAxis::Vertical, + "DCALIGNED" => DimConstraintAxis::Aligned, + _ => DimConstraintAxis::Linear, + }; + let name = next_dimensional_parameter_name(self.tabs[i].scene.named_parameters()); + // Both constraint points are picked inside the command. + self.tabs[i].scene.deselect_all(); + let command = DimConstraintCommand::new(axis, name); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "DCONSTRAINT" | "DCRADIUS" | "DCDIAMETER" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; diff --git a/src/command.rs b/src/command.rs index 6655c5351..b1b9d48e9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1610,6 +1610,25 @@ pub enum CmdResult { multiple: bool, label: &'static str, }, + /// Resolves a constraint-point pick against the live document and hands + /// it back through `CadCommand::accept_constraint_point`; a miss reports + /// `No valid constraint point found.` and asks again. + CheckConstraintPoint(CoincidentPick), + /// Adds a dimensional constraint between two constraint points together + /// with the dynamic dimension that shows it and the parameter + /// (`name` = `expression`) that drives it. + AddDimensionalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind, + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + first_point: DVec3, + second_point: DVec3, + location: DVec3, + axis: DVec3, + name: String, + expression: String, + label: &'static str, + }, /// Adds a point or object symmetry relation around a picked line. The /// first reference and axis remain fixed during initial placement. AddSymmetricConstraint { @@ -2453,6 +2472,16 @@ pub trait CadCommand: Send { false } + /// A constraint point the host resolved for a `CheckConstraintPoint` + /// pick, with its world position. + fn accept_constraint_point( + &mut self, + _reference: crate::scene::parametric_constraints::ParametricRef, + _point: DVec3, + ) -> CmdResult { + CmdResult::NeedPoint + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 36013041b..179cefed2 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -67,26 +67,36 @@ impl AlignedDimensionCommand { let first = self.plane.to_local(first); let second = self.plane.to_local(second); let point = self.plane.to_local(point); - let mut dim = DimensionAligned::new(v3(first), v3(second)); - // The placement point is stored so commit matches the preview. - dim.definition_point = v3(point); - dim.base.definition_point = v3(point); - let (d1, d2) = dim_line_endpoints(first, second, point); - dim.base.text_middle_point = v3((d1 + d2) * 0.5); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - crate::entities::dimension::set_dimension_text_override( - &mut dim.base, - self.text_override.clone(), - ); + let mut entity = aligned_dimension_entity(first, second, point, self.text_override.clone()); // An explicit text angle overrides the default rotation. - if let Some(angle) = self.text_angle { - dim.base.text_rotation = angle; + if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) { + dimension.base_mut().text_rotation = angle; } - self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))) + self.plane.place_entity(entity) } } +/// An aligned dimension between two points of the working plane, its +/// dimension line through `point` — what DIMALIGNED places and what an +/// aligned dimensional constraint draws. +pub(crate) fn aligned_dimension_entity( + first: DVec3, + second: DVec3, + point: DVec3, + text_override: Option, +) -> EntityType { + let mut dim = DimensionAligned::new(v3(first), v3(second)); + // The placement point is stored so commit matches the preview. + dim.definition_point = v3(point); + dim.base.definition_point = v3(point); + let (d1, d2) = dim_line_endpoints(first, second, point); + dim.base.text_middle_point = v3((d1 + d2) * 0.5); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.actual_measurement = dim.measurement(); + crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override); + EntityType::Dimension(Dimension::Aligned(dim)) +} + impl CadCommand for AlignedDimensionCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 5e9b00c94..ed6066506 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -15,7 +15,7 @@ use glam::DVec3; use crate::t; /// Select the measured axis from where the dimension line clears the points. -fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 { +pub(crate) fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 { let outside = |value: f64, a: f64, b: f64| { let (low, high) = if a <= b { (a, b) } else { (b, a) }; (low - value).max(value - high).max(0.0) @@ -113,26 +113,38 @@ impl LinearDimensionCommand { let first = self.plane.to_local(first); let second = self.plane.to_local(second); let point = self.plane.to_local(point); - let mut dim = DimensionLinear::new(v3(first), v3(second)); let axis = self.axis_mode.axis(first, second, point); - dim.rotation = axis.y.atan2(axis.x); - dim.set_offset(dimension_line_offset(second, point, axis)); - dim.base.definition_point = dim.definition_point; - dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis)); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - crate::entities::dimension::set_dimension_text_override( - &mut dim.base, - self.text_override.clone(), - ); + let mut entity = + linear_dimension_entity(first, second, point, axis, self.text_override.clone()); // An explicit text angle overrides the UCS-derived rotation. - if let Some(angle) = self.text_angle { - dim.base.text_rotation = angle; + if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) { + dimension.base_mut().text_rotation = angle; } - self.plane.place_entity(EntityType::Dimension(Dimension::Linear(dim))) + self.plane.place_entity(entity) } } +/// A linear dimension between two points of the working plane, its +/// dimension line through `point` along `axis` — what DIMLINEAR places and +/// what a dynamic dimensional constraint draws. +pub(crate) fn linear_dimension_entity( + first: DVec3, + second: DVec3, + point: DVec3, + axis: DVec3, + text_override: Option, +) -> EntityType { + let mut dim = DimensionLinear::new(v3(first), v3(second)); + dim.rotation = axis.y.atan2(axis.x); + dim.set_offset(dimension_line_offset(second, point, axis)); + dim.base.definition_point = dim.definition_point; + dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis)); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.actual_measurement = dim.measurement(); + crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override); + EntityType::Dimension(Dimension::Linear(dim)) +} + impl CadCommand for LinearDimensionCommand { fn name(&self) -> &'static str { "DIMLINEAR" diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs new file mode 100644 index 000000000..a4ccb007d --- /dev/null +++ b/src/modules/parametric/dim_constraint.rs @@ -0,0 +1,445 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; +use crate::scene::parametric_constraints::{ + measured_expression, resolve_point, ConstraintKind, ParametricRef, +}; + +/// Which distance a dimensional constraint measures. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DimConstraintAxis { + /// Horizontal or vertical, decided by where the dimension line goes. + Linear, + Horizontal, + Vertical, + Aligned, +} + +#[derive(Clone, Copy)] +enum Step { + /// `Specify first constraint point or [Object] :` + First, + /// `Select object:` + Object, + /// `Specify second constraint point:` + Second { + first: ParametricRef, + first_point: DVec3, + }, + /// `Specify dimension line location:` + Location { + first: ParametricRef, + second: ParametricRef, + first_point: DVec3, + second_point: DVec3, + }, + /// `Enter value or name and value :` + Value { + first: ParametricRef, + second: ParametricRef, + first_point: DVec3, + second_point: DVec3, + location: DVec3, + axis: DVec3, + kind: ConstraintKind, + measured: f64, + }, +} + +/// The reference's Linear/Horizontal/Vertical/Aligned dimensional constraint: +/// two constraint points (or one object's ends), a dimension line location, +/// then the parameter name and expression the dynamic dimension carries. +pub struct DimConstraintCommand { + axis: DimConstraintAxis, + step: Step, + picked_entity: Option, + /// The next free `dN` the host reserved for this constraint. + default_name: String, +} + +impl DimConstraintCommand { + pub const NO_OBJECT: &'static str = "No object found."; + pub const NO_POINT: &'static str = "No valid constraint point found."; + pub const SAME_POINT: &'static str = + "The object or point is already selected. Select a different object or constraint point."; + + pub fn new(axis: DimConstraintAxis, default_name: String) -> Self { + Self { + axis, + step: Step::First, + picked_entity: None, + default_name, + } + } + + fn command_name(&self) -> &'static str { + match self.axis { + DimConstraintAxis::Linear => "DCLINEAR", + DimConstraintAxis::Horizontal => "DCHORIZONTAL", + DimConstraintAxis::Vertical => "DCVERTICAL", + DimConstraintAxis::Aligned => "DCALIGNED", + } + } + + fn noun(&self) -> &'static str { + match self.axis { + DimConstraintAxis::Linear => "Linear", + DimConstraintAxis::Horizontal => "Horizontal", + DimConstraintAxis::Vertical => "Vertical", + DimConstraintAxis::Aligned => "Aligned", + } + } + + fn report(message: &str) -> CmdResult { + CmdResult::ReportError(message.to_string()) + } + + /// The two end points an object pick constrains: a line's or an arc's + /// ends, the picked polyline segment's vertices. + fn object_ends( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option<(ParametricRef, ParametricRef)> { + match entity { + EntityType::Line(_) | EntityType::Arc(_) => { + Some((ParametricRef::point(handle, 0), ParametricRef::point(handle, 1))) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; + let index = source.segment_index; + // A closed polyline's last segment ends at the first vertex. + let end = if resolve_point(entity, index + 1).is_some() { + index + 1 + } else { + 0 + }; + Some((ParametricRef::point(handle, index), ParametricRef::point(handle, end))) + } + _ => None, + } + } + + fn world(entity: &EntityType, reference: ParametricRef) -> Option { + let point = resolve_point(entity, reference.marker?)?; + Some(DVec3::new(point.x, point.y, point.z)) + } + + /// The measured kind and axis: Linear reads the dimension line location + /// the way a linear dimension does, the others are fixed. + fn decide(&self, first: DVec3, second: DVec3, location: DVec3) -> (ConstraintKind, DVec3) { + match self.axis { + DimConstraintAxis::Linear => { + let axis = + crate::modules::annotate::linear_dim::measure_axis(first, second, location); + if axis.x.abs() > 0.5 { + (ConstraintKind::DistanceX, DVec3::X) + } else { + (ConstraintKind::DistanceY, DVec3::Y) + } + } + DimConstraintAxis::Horizontal => (ConstraintKind::DistanceX, DVec3::X), + DimConstraintAxis::Vertical => (ConstraintKind::DistanceY, DVec3::Y), + DimConstraintAxis::Aligned => ( + ConstraintKind::Distance, + (second - first).normalize_or(DVec3::X), + ), + } + } + + fn measured(kind: ConstraintKind, first: DVec3, second: DVec3) -> f64 { + match kind { + ConstraintKind::DistanceX => (second.x - first.x).abs(), + ConstraintKind::DistanceY => (second.y - first.y).abs(), + _ => (second - first).length(), + } + } + + fn build(&self, name: String, expression: String) -> Option { + let Step::Value { + first, + second, + first_point, + second_point, + location, + axis, + kind, + .. + } = self.step + else { + return None; + }; + if expression.trim().is_empty() { + return None; + } + Some(CmdResult::AddDimensionalConstraint { + kind, + first, + second, + first_point, + second_point, + location, + axis, + name, + expression, + label: match self.axis { + DimConstraintAxis::Linear => "Linear constraint", + DimConstraintAxis::Horizontal => "Horizontal distance constraint", + DimConstraintAxis::Vertical => "Vertical distance constraint", + DimConstraintAxis::Aligned => "Aligned constraint", + }, + }) + } +} + +impl CadCommand for DimConstraintCommand { + fn name(&self) -> &'static str { + self.command_name() + } + + fn prompt(&self) -> String { + let name = self.command_name(); + match self.step { + Step::First => { + format!("{name} Specify first constraint point or [Object] :") + } + Step::Object => format!("{name} Select object:"), + Step::Second { .. } => format!("{name} Specify second constraint point:"), + Step::Location { .. } => format!("{name} Specify dimension line location:"), + Step::Value { measured, .. } => format!( + "{name} Enter value or name and value <{}={}>:", + self.default_name, + measured_expression(measured) + ), + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::First) + .then(|| vec![CmdOption::new("Object", "O")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::First | Step::Value { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + match self.step { + Step::First => { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(keyword.as_str(), "O" | "OBJECT") { + self.step = Step::Object; + return Some(CmdResult::NeedPoint); + } + None + } + Step::Value { .. } => { + let text = text.trim(); + let (name, expression) = match text.split_once('=') { + Some((name, expression)) => (name.trim().to_string(), expression.trim().to_string()), + None => (self.default_name.clone(), text.to_string()), + }; + self.build(name, expression) + } + _ => None, + } + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::First | Step::Object | Step::Second { .. }) + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn typed_point_picks_entity(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + match self.step { + // The host resolves the constraint point and hands it back + // through `accept_constraint_point`. + Step::First | Step::Second { .. } => CmdResult::CheckConstraintPoint(CoincidentPick { + handle: (!handle.is_null()).then_some(handle), + point, + whole_curve: false, + }), + Step::Object => { + if handle.is_null() { + return Self::report(Self::NO_OBJECT); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some((first, second)) = Self::object_ends(&entity, handle, point) else { + return Self::report(&format!( + "Invalid selection for {}. Select a line, polyline segment or arc.", + self.noun() + )); + }; + let (Some(first_point), Some(second_point)) = + (Self::world(&entity, first), Self::world(&entity, second)) + else { + return Self::report(Self::NO_POINT); + }; + self.step = Step::Location { + first, + second, + first_point, + second_point, + }; + CmdResult::NeedPoint + } + Step::Location { .. } | Step::Value { .. } => self.on_point(point), + } + } + + fn accept_constraint_point(&mut self, reference: ParametricRef, point: DVec3) -> CmdResult { + match self.step { + Step::First => { + self.step = Step::Second { + first: reference, + first_point: point, + }; + CmdResult::NeedPoint + } + Step::Second { first, first_point } => { + if first == reference { + return Self::report(Self::SAME_POINT); + } + self.step = Step::Location { + first, + second: reference, + first_point, + second_point: point, + }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::First | Step::Second { .. } => CmdResult::CheckConstraintPoint(CoincidentPick { + handle: None, + point, + whole_curve: false, + }), + Step::Object => Self::report(Self::NO_OBJECT), + Step::Location { + first, + second, + first_point, + second_point, + } => { + let (kind, axis) = self.decide(first_point, second_point, point); + let measured = Self::measured(kind, first_point, second_point); + self.step = Step::Value { + first, + second, + first_point, + second_point, + location: point, + axis, + kind, + measured, + }; + CmdResult::ReportMeasurement(format!("Dimension text = {measured:.4}")) + } + Step::Value { .. } => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::First => { + self.step = Step::Object; + CmdResult::NeedPoint + } + Step::Value { measured, .. } => self + .build(self.default_name.clone(), measured_expression(measured)) + .unwrap_or(CmdResult::Cancel), + _ => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn object_pick_takes_the_line_ends_then_asks_for_the_location() { + let mut command = DimConstraintCommand::new(DimConstraintAxis::Linear, "d1".into()); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(100.0, 50.0, 0.0), + ))); + assert!(matches!( + command.on_entity_pick(Handle::new(7), DVec3::new(50.0, 25.0, 0.0)), + CmdResult::NeedPoint + )); + // Below the pair: horizontal, so the measured value is the X span. + let CmdResult::ReportMeasurement(text) = command.on_point(DVec3::new(50.0, -30.0, 0.0)) + else { + panic!("the location must report the dimension text"); + }; + assert_eq!(text, "Dimension text = 100.0000"); + assert_eq!( + command.prompt(), + "DCLINEAR Enter value or name and value :" + ); + let CmdResult::AddDimensionalConstraint { + kind, name, expression, .. + } = command.on_text_input("w=d1*2").expect("a value builds the constraint") + else { + panic!("the value must build the constraint"); + }; + assert_eq!(kind, ConstraintKind::DistanceX); + assert_eq!(name, "w"); + assert_eq!(expression, "d1*2"); + } + + #[test] + fn a_location_beside_the_pair_measures_vertically() { + let mut command = DimConstraintCommand::new(DimConstraintAxis::Linear, "d1".into()); + let first = ParametricRef::point(Handle::new(7), 0); + let second = ParametricRef::point(Handle::new(7), 1); + command.accept_constraint_point(first, DVec3::ZERO); + command.accept_constraint_point(second, DVec3::new(100.0, 50.0, 0.0)); + let CmdResult::ReportMeasurement(text) = command.on_point(DVec3::new(140.0, 25.0, 0.0)) + else { + panic!("the location must report the dimension text"); + }; + assert_eq!(text, "Dimension text = 50.0000"); + } +} diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index ebe13aa90..44ad59d44 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -4,6 +4,7 @@ mod coincident; mod concentric; +mod dim_constraint; mod constraint_bar; mod equal_distance; #[path = "equal.rs"] @@ -25,6 +26,7 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; +pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand}; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e2b373127..60821cba1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2295,6 +2295,9 @@ pub struct Scene { has_associative_centers: std::cell::Cell>, /// Runtime parametric constraint sets decoded from standard graph scopes. pub(crate) parametric_constraints: Vec, + /// CONSTRAINTNAMEFORMAT: what a dynamic dimension's text shows — + /// 0 the parameter name, 1 the value, 2 `name=value`. + pub constraint_name_format: u8, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2625,6 +2628,7 @@ impl Scene { dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), + constraint_name_format: 2, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), @@ -3319,6 +3323,7 @@ impl Scene { } } if !self.parametric_constraints.is_empty() || !self.named_parameters.is_empty() { + self.refresh_dynamic_dimension_texts(); self.sync_native_parametric_graph(); } if !changes.is_empty() { diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 0ab068550..4f9317a75 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -351,6 +351,37 @@ pub(crate) fn equal_size_follower( Some(entity) } +/// The first free `d1`, `d2`, … name a new dimensional constraint takes. +pub(crate) fn next_dimensional_parameter_name( + table: &super::named_parameters::ParameterTable, +) -> String { + (1..) + .map(|n| format!("d{n}")) + .find(|name| !table.contains(name)) + .unwrap_or_else(|| "d1".to_string()) +} + +/// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, +/// 1 = value, 2 = name=value. +pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { + match format { + 0 => name.to_string(), + 1 => format!("{value:.4}"), + _ => format!("{name}={value:.4}"), + } +} + +/// The expression a measured value seeds a parameter with (`100`, `111.8034`). +pub(crate) fn measured_expression(value: f64) -> String { + let text = format!("{value:.4}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + if text.is_empty() || text == "-" { + "0".to_string() + } else { + text.to_string() + } +} + /// Grabbed points are exact kernel inputs; the solver anchors the remaining /// endpoint coordinates according to the line's directional constraints. pub(crate) fn grip_solve_anchor_refs( @@ -585,6 +616,8 @@ pub struct ParametricConstraintSet { /// parameter names may legitimately occur in different blocks. pub(crate) local_parameters: super::named_parameters::ParameterTable, pub(crate) retained_standard_groups: Vec, + /// The dynamic dimension that shows a dimensional constraint, by id. + pub(crate) dimensions: std::collections::HashMap, /// Cached total remaining degrees of freedom, summed across every /// independent solve partition in this scope — updated by /// `parametric_solve::solve_scope` each time this set is resolved. `None` @@ -611,6 +644,7 @@ impl ParametricConstraintSet { next_id: 0, local_parameters: super::named_parameters::ParameterTable::new(), retained_standard_groups: Vec::new(), + dimensions: std::collections::HashMap::new(), dof: None, conflicts: Vec::new(), } @@ -702,6 +736,7 @@ impl ParametricConstraintSet { /// Removes a constraint by id. Returns whether one was actually removed. pub fn remove(&mut self, id: ConstraintId) -> bool { + self.dimensions.remove(&id); let before = self.constraints.len(); self.constraints.retain(|c| c.id != id); self.constraints.len() != before @@ -1420,6 +1455,49 @@ impl super::Scene { !self.hidden_parametric_constraints.contains(&(scope, id)) } + /// Rewrites every dynamic dimension's text from its constraint's + /// parameter name and current value, in the CONSTRAINTNAMEFORMAT + /// reading `constraint_name_format` selects. + pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { + let format = self.constraint_name_format; + let mut updates: Vec<(Handle, String)> = Vec::new(); + for set in &self.parametric_constraints { + let table = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; + for (id, dimension) in &set.dimensions { + let Some(constraint) = set.get(*id) else { + continue; + }; + let (name, value) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => { + (name.clone(), table.resolve(name).unwrap_or(f64::NAN)) + } + Some(DrivingValue::Literal(value)) => (String::new(), *value), + None => continue, + }; + updates.push((*dimension, dynamic_dimension_text(&name, value, format))); + } + } + for (handle, text) in updates { + let Some(acadrust::EntityType::Dimension(mut dimension)) = + self.document.get_entity(handle).cloned() + else { + continue; + }; + if dimension.base().user_text.as_deref() == Some(text.as_str()) { + continue; + } + crate::entities::dimension::set_dimension_text_override( + dimension.base_mut(), + Some(text), + ); + self.update_entity(acadrust::EntityType::Dimension(dimension)); + } + } + pub fn should_display_parametric_constraint( &self, scope: ParametricScope, @@ -1861,7 +1939,8 @@ impl super::Scene { }; set.constraints .iter() - .filter(|c| c.enabled) + // A dynamic dimension is its constraint's whole display. + .filter(|c| c.enabled && !set.dimensions.contains_key(&c.id)) .filter(|c| { let selected = c .refs From 971abe64a4e2232bba871e9b67445cb659b98236 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:02:21 +0300 Subject: [PATCH 2/5] Dimensional constraints: dynamic dimension persistence, lock mark, display variables, DIMCONSTRAINT The dynamic dimension of a dimensional constraint is now linked in the drawing file (AcDbAssocDependency + AcDbAssocDimDependencyBody on the constraint node), reread on load, hidden by DCHIDE/DYNCONSTRAINTDISPLAY and never plotted; it carries a lock mark before its text. New CONSTRAINTNAMEFORMAT and DYNCONSTRAINTDISPLAY variables and the DIMCONSTRAINT option front end. A transformed entity whose own end points a driving dimension measures keeps only the first constraint point, so a scaled line returns to its dimension value. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 2 + src/app/commands/draw.rs | 10 ++ src/app/commands/styleprops.rs | 23 +++- src/entities/dimension.rs | 39 +++++++ src/modules/parametric/dim_constraint.rs | 64 ++++++++++ src/modules/parametric/mod.rs | 4 +- src/scene/dwg_native_constraints.rs | 142 ++++++++++++++++++++++- src/scene/entity.rs | 3 + src/scene/mod.rs | 7 ++ src/scene/parametric_constraints.rs | 96 ++++++++++++++- src/scene/parametric_solve.rs | 26 ++++- src/ui/overlay.rs | 41 ++++--- 12 files changed, 433 insertions(+), 24 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6a3584ab0..1c435fd22 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3686,6 +3686,8 @@ impl OpenCADStudio { &[first], self.constraint_solve_mode, ); + // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 79d66aa27..cf9d4686d 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1506,6 +1506,16 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DIMCONSTRAINT" => { + use crate::modules::parametric::DimConstraintMenuCommand; + // Only the dynamic form exists here. + self.command_line + .push_output("Current settings: Constraint form = Dynamic"); + let new_cmd = DimConstraintMenuCommand; + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 0b5bc9b14..e997869cd 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1052,6 +1052,8 @@ impl OpenCADStudio { | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" | "CONSTRAINTBARMODE" + | "CONSTRAINTNAMEFORMAT" + | "DYNCONSTRAINTDISPLAY" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1075,7 +1077,7 @@ impl OpenCADStudio { if name.is_empty() || name == "?" { self.command_line.push_info(&super::plotvars::setvar_listing()); self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1275,17 +1277,26 @@ impl OpenCADStudio { | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" | "CONSTRAINTBARMODE" + | "CONSTRAINTNAMEFORMAT" + | "DYNCONSTRAINTDISPLAY" ) { let current = match name.as_str() { "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), "CONSTRAINTINFER" => i16::from(self.constraint_infer), "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, "CONSTRAINTBARMODE" => self.constraint_bar_mode, + "CONSTRAINTNAMEFORMAT" => { + i16::from(self.tabs[i].scene.constraint_name_format) + } + "DYNCONSTRAINTDISPLAY" => { + i16::from(self.tabs[i].scene.dynamic_constraint_display) + } _ => unreachable!(), }; let maximum = match name.as_str() { "CONSTRAINTBARDISPLAY" => 3, "CONSTRAINTBARMODE" => 4095, + "CONSTRAINTNAMEFORMAT" => 2, _ => 1, }; if let Some(value) = &value { @@ -1304,6 +1315,16 @@ impl OpenCADStudio { self.constraint_bar_display = mode } "CONSTRAINTBARMODE" => self.constraint_bar_mode = mode, + "CONSTRAINTNAMEFORMAT" => { + self.tabs[i].scene.constraint_name_format = mode as u8; + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].dirty = true; + } + "DYNCONSTRAINTDISPLAY" => { + self.tabs[i].scene.dynamic_constraint_display = + mode != 0; + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + } _ => unreachable!(), } self.persist_settings_if_changed(); diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 6cf37e0a5..906dea36d 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,6 +6295,45 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } } +/// Where a dynamic dimension's lock mark sits: just before the text on its +/// baseline, with the direction that points away from the text. +pub(crate) fn dynamic_dimension_lock_anchor( + document: &CadDocument, + dim: &Dimension, + anno_scale: f64, +) -> Option<(Vector3, Vector3)> { + let style_name = &dim.base().style_name; + let source_style = document.dim_styles.iter().find(|s| { + s.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) + }); + let effective_style = source_style.map(|style| resolved_dimension_style(style, dim, document)); + let style = effective_style.as_ref(); + let dim_scale = style + .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { anno_scale }) + .unwrap_or(1.0); + let text_height = style.map(|s| s.dimtxt * dim_scale).unwrap_or(2.5); + let value = dimension_text_value(dim, style)?; + let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); + let half_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH * 0.5; + let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); + let rot = if dim.base().text_rotation.abs() > 1e-9 { + dim.base().text_rotation + } else { + dimension_text_natural_rotation(dim) + }; + let (sr, cr) = rot.sin_cos(); + let outward = Vector3::new(-cr, -sr, 0.0); + Some(( + Vector3::new( + pos.x + outward.x * half_width, + pos.y + outward.y * half_width, + pos.z, + ), + outward, + )) +} + fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let angle = match dim { Dimension::Linear(d) => d.rotation, diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index a4ccb007d..522119203 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -390,6 +390,70 @@ impl CadCommand for DimConstraintCommand { } } +/// `DIMCONSTRAINT`: the reference's option front end for the dimensional +/// constraint family; each choice runs the focused command. +pub struct DimConstraintMenuCommand; + +impl DimConstraintMenuCommand { + fn dispatch(keyword: &str) -> Option<&'static str> { + Some(match keyword { + "L" | "LINEAR" => "DCLINEAR", + "H" | "HORIZONTAL" => "DCHORIZONTAL", + "V" | "VERTICAL" => "DCVERTICAL", + "A" | "ALIGNED" => "DCALIGNED", + "AN" | "ANGULAR" => "DCANGULAR", + "R" | "RADIAL" | "RADIUS" => "DCRADIUS", + "D" | "DIAMETER" => "DCDIAMETER", + "C" | "CONVERT" => "DCCONVERT", + _ => return None, + }) + } +} + +impl CadCommand for DimConstraintMenuCommand { + fn name(&self) -> &'static str { + "DIMCONSTRAINT" + } + + fn prompt(&self) -> String { + "DIMCONSTRAINT Enter dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radial/Diameter/Convert] :".to_string() + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Linear", "L"), + CmdOption::new("Horizontal", "H"), + CmdOption::new("Vertical", "V"), + CmdOption::new("Aligned", "A"), + CmdOption::new("ANgular", "AN"), + CmdOption::new("Radial", "R"), + CmdOption::new("Diameter", "D"), + CmdOption::new("Convert", "C"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string())) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("DCALIGNED".to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 44ad59d44..6353d3672 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -26,7 +26,7 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; -pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand}; +pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand}; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; @@ -158,7 +158,7 @@ inventory::submit!(crate::command::CommandRegistration { names: &[ "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", - "DCCONVERT", "DELCONSTRAINT", + "DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", ] }); diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index e36ca0c6d..dc8174bf5 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2,7 +2,8 @@ use acadrust::objects::{ Assoc2dConstraintGroup, AssocAction, AssocActionDependency, AssocConstraintNode, - AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant, + AssocConstraintNodeData, AssocDependency, AssocDimDependencyBody, AssocEvalValue, + AssocEvalVariant, AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency, AssocVariable, AssociativeData, AssociativeObject, ObjectType, }; @@ -1291,6 +1292,67 @@ impl Allocator<'_> { } handle } + + /// The `AcDbAssocDependency` + `AcDbAssocDimDependencyBody` pair that + /// ties a dimensional constraint to the dynamic dimension showing it. + fn dimension_dependency( + &mut self, + group_handle: Handle, + dimension: Handle, + name: &str, + dependency_id: i32, + ) -> Handle { + let handle = self.document.allocate_handle(); + let body = self.insert_associative( + handle, + "ACDBASSOCDIMDEPENDENCYBODY", + "AcDbAssocDimDependencyBody", + AssociativeData::DimDependencyBody(AssocDimDependencyBody { + dependency_body_version: 1, + base_version: 1, + name: name.to_string(), + class_version: 1, + }), + ); + self.insert_associative_at( + handle, + group_handle, + "ACDBASSOCDEPENDENCY", + "AcDbAssocDependency", + AssociativeData::Dependency(AssocDependency { + class_version: 2, + is_read_dependency: true, + is_write_dependency: true, + is_attached_to_object: true, + is_delegating_to_owning_action: true, + dependent_on: dimension, + dependency_body: body, + dependency_body_id: dependency_id, + ..Default::default() + }), + ); + // Earlier materializations' dependencies are gone by now; drop + // their reactor entries before adding this one. + let live: Vec = self + .document + .get_entity(dimension) + .map(|entity| { + entity + .common() + .reactors + .iter() + .copied() + .filter(|reactor| self.document.objects.contains_key(reactor)) + .collect() + }) + .unwrap_or_default(); + if let Some(entity) = self.document.get_entity_mut(dimension) { + let reactors = &mut entity.common_mut().reactors; + *reactors = live; + reactors.push(handle); + } + handle + } } /// Sets the dependency on whole-geometry nodes. Implicit points retain a @@ -1355,6 +1417,27 @@ fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { } } +/// Patches a dimensional-constraint node's `dimension_dependency` field. +fn set_dimension_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { + match data { + AssocConstraintNodeData::Distance { + dimension_dependency, + .. + } + | AssocConstraintNodeData::Angle { + dimension_dependency, + .. + } + | AssocConstraintNodeData::RadiusDiameter { + dimension_dependency, + .. + } => { + *dimension_dependency = handle; + } + _ => {} + } +} + /// Associative classes that must have real class numbers in DWG/DXF. const ASSOC_CLASSES: &[(&str, &str, i32)] = &[ ( @@ -1366,6 +1449,8 @@ const ASSOC_CLASSES: &[(&str, &str, i32)] = &[ ("ACDBASSOCVARIABLE", "AcDbAssocVariable", 45), ("ACDBASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency", 29), ("ACDBASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency", 29), + ("ACDBASSOCDEPENDENCY", "AcDbAssocDependency", 29), + ("ACDBASSOCDIMDEPENDENCYBODY", "AcDbAssocDimDependencyBody", 45), ]; fn ensure_associative_classes_registered(document: &mut CadDocument) { @@ -1806,6 +1891,19 @@ fn driving_value( } } +/// The dynamic dimension an `AcDbAssocDependency` ties to a dimensional +/// constraint node. +fn dependency_dimension(document: &CadDocument, dependency: Handle) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::Dependency(dependency) = &object.data else { + return None; + }; + let target = dependency.dependent_on; + matches!(document.get_entity(target)?, EntityType::Dimension(_)).then_some(target) +} + fn owning_composite(data: &AssocConstraintNodeData) -> Option { match data { AssocConstraintNodeData::Geometrical { owner_id, .. } @@ -2250,7 +2348,25 @@ pub(super) fn native_constraint_set( { continue; } - set.add(kind, targets, driving); + let dimension = match &node.data { + AssocConstraintNodeData::Angle { + dimension_dependency, + .. + } + | AssocConstraintNodeData::Distance { + dimension_dependency, + .. + } + | AssocConstraintNodeData::RadiusDiameter { + dimension_dependency, + .. + } => dependency_dimension(document, *dimension_dependency), + _ => None, + }; + let id = set.add(kind, targets, driving); + if let Some(dimension) = dimension { + set.dimensions.insert(id, dimension); + } if let Some(constraint) = set.constraints.last_mut() { constraint.enabled = enabled; constraint.rigid_points = rigid_points; @@ -2429,15 +2545,21 @@ fn materialize_scope( // Pass 1 (read-only): constraint-node shapes. let mut needs_value_dependency = Vec::new(); + let mut dimension_links = Vec::new(); let (mut nodes, entities, referenced_entities) = { let mut builder = GroupBuilder::new(document); for constraint in &set.constraints { - constraint_node( + let node_id = constraint_node( &mut builder, document, constraint, &mut needs_value_dependency, ); + if let Some((node_id, dimension)) = node_id.zip(set.dimensions.get(&constraint.id)) { + if document.get_entity(*dimension).is_some() { + dimension_links.push((node_id, *dimension)); + } + } } let GroupBuilder { nodes, @@ -2527,6 +2649,7 @@ fn materialize_scope( } let mut group_dependencies = geometry_dependencies; + let mut node_names: FxHashMap = FxHashMap::default(); for (node_id, driving) in needs_value_dependency { let Some(driving) = driving else { continue }; let Ok(resolved) = driving.resolve(parameters) else { @@ -2542,6 +2665,7 @@ fn materialize_scope( } DrivingValue::Literal(_) => (format!("d{node_id}"), resolved.to_string()), }; + node_names.insert(node_id, name.clone()); let variable_handle = allocator.variable(network_handle, &name, &formula, resolved); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = @@ -2551,6 +2675,18 @@ fn materialize_scope( set_value_dependency(&mut node.data, dep_handle); } } + for (node_id, dimension) in dimension_links { + let Some(name) = node_names.get(&node_id) else { + continue; + }; + let dependency_id = group_dependencies.len() as i32 + 1; + let dep_handle = + allocator.dimension_dependency(group_handle?, dimension, name, dependency_id); + group_dependencies.push(dep_handle); + if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { + set_dimension_dependency(&mut node.data, dep_handle); + } + } if let Some(group_handle) = group_handle { let group = Assoc2dConstraintGroup { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 2c2d105df..0fdc9a53d 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1402,6 +1402,9 @@ impl Scene { } if self.object_isolation.hides(common.handle) || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) + || self.hidden_dynamic_dimensions.contains(&common.handle) + // A dynamic dimension never plots. + || (plot_only && self.is_dynamic_dimension(common.handle)) { return false; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 60821cba1..bf4dec17d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2298,6 +2298,10 @@ pub struct Scene { /// CONSTRAINTNAMEFORMAT: what a dynamic dimension's text shows — /// 0 the parameter name, 1 the value, 2 `name=value`. pub constraint_name_format: u8, + /// DYNCONSTRAINTDISPLAY: whether dynamic dimensions draw at all. + pub dynamic_constraint_display: bool, + /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. + hidden_dynamic_dimensions: HashSet, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2629,6 +2633,8 @@ impl Scene { associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), constraint_name_format: 2, + dynamic_constraint_display: true, + hidden_dynamic_dimensions: HashSet::default(), hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), @@ -5538,6 +5544,7 @@ impl Scene { self.object_isolation.hides(handle) || self.preview_hidden.contains(&handle) || self.command_preview_hidden.contains(&handle) + || self.hidden_dynamic_dimensions.contains(&handle) } /// Replace the command-owned source hide set and refresh only handles whose diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 4f9317a75..eb8e2c0f6 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -361,6 +361,9 @@ pub(crate) fn next_dimensional_parameter_name( .unwrap_or_else(|| "d1".to_string()) } +/// The constraint-bar label that draws a dynamic dimension's lock mark. +pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, /// 1 = value, 2 = name=value. pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { @@ -1561,9 +1564,45 @@ impl super::Scene { self.shown_parametric_constraints.remove(&(scope, *id)); } } + self.refresh_hidden_dynamic_dimensions(); ids.len() } + /// True for a dimension that shows a dimensional constraint. + pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { + self.parametric_constraints + .iter() + .any(|set| set.dimensions.values().any(|dimension| *dimension == handle)) + } + + /// Re-derives which dynamic dimensions stay off screen (DCHIDE, or + /// DYNCONSTRAINTDISPLAY 0 for all of them) and redraws the ones that + /// changed. + pub(crate) fn refresh_hidden_dynamic_dimensions(&mut self) { + let desired: rustc_hash::FxHashSet = self + .parametric_constraints + .iter() + .flat_map(|set| { + set.dimensions.iter().filter_map(|(id, dimension)| { + (!self.dynamic_constraint_display + || !self.is_parametric_constraint_visible(set.scope, *id)) + .then_some(*dimension) + }) + }) + .collect(); + if desired == self.hidden_dynamic_dimensions { + return; + } + let changes: Vec<_> = self + .hidden_dynamic_dimensions + .symmetric_difference(&desired) + .copied() + .map(|handle| (handle, super::ChangeKind::Modified)) + .collect(); + self.hidden_dynamic_dimensions = desired; + self.bump_entities(&changes); + } + /// Infers relations already present in the selected geometry. pub fn inferred_parametric_constraints( &self, @@ -1937,7 +1976,8 @@ impl super::Scene { let cam = self.camera.borrow(); (cam.view_proj_rte(bounds), cam.eye()) }; - set.constraints + let mut placements = set + .constraints .iter() // A dynamic dimension is its constraint's whole display. .filter(|c| c.enabled && !set.dimensions.contains_key(&c.id)) @@ -2031,7 +2071,59 @@ impl super::Scene { }) .collect::>() }) - .collect() + .collect::>(); + // A dynamic dimension carries the reference's lock mark at the + // start of its text instead of a constraint bar. + for (id, dimension) in &set.dimensions { + let Some(constraint) = set.get(*id) else { + continue; + }; + if !constraint.enabled || self.entity_temporarily_hidden(*dimension) { + continue; + } + let Some(acadrust::EntityType::Dimension(entity)) = self.document.get_entity(*dimension) + else { + continue; + }; + let Some((anchor, outward)) = crate::entities::dimension::dynamic_dimension_lock_anchor( + &self.document, + entity, + self.annotation_scale as f64, + ) else { + continue; + }; + let project = |point: Vector3| { + crate::scene::pick::grip::project_rte( + glam::DVec3::new(point.x, point.y, point.z), + view_rot, + eye, + bounds, + ) + }; + let (Some(screen), Some(outward_screen)) = ( + project(anchor), + project(Vector3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + )), + ) else { + continue; + }; + let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_X); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + if point.x.is_finite() && point.y.is_finite() { + placements.push(( + *id, + point, + direction.to_array(), + DYNAMIC_DIMENSION_GLYPH.to_string(), + false, + Vec::new(), + )); + } + } + placements } /// Hit-tests screen point `p` (same coordinate space as `p_full` in the diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 59a5d30ca..c337bf2e7 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -2441,7 +2441,31 @@ fn solve_scope( }) }) .flatten(); - let pinned = axis_pin.unwrap_or(*reference); + // A transformed entity whose own end points a driving dimension + // measures keeps only the first constraint point where the transform + // put it; the dimension pulls the other end back to its value (a + // scaled line keeps its d1), as in the reference. + let dimension_pin = (reference.marker.is_none() && axis_pin.is_none()) + .then(|| { + constraints.iter().find_map(|c| { + (c.enabled + && c.driving_param.is_some() + && matches!( + c.kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + ) + && c.refs.len() >= 2 + && c.refs + .iter() + .all(|r| r.entity == reference.entity && r.marker.is_some())) + .then(|| c.refs[0]) + }) + }) + .flatten(); + let pinned = axis_pin.or(dimension_pin).unwrap_or(*reference); // The re-aligned entity keeps the length the transform gave it (a // scaled vertical line stays scaled), not its pre-edit length. if axis_pin.is_some() { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 74a828a09..fbf49514c 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -39,6 +39,11 @@ fn is_fixed_glyph(label: &str) -> bool { label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH } +/// The lock mark a dynamic dimension carries (`DYNAMIC_DIMENSION_GLYPH`). +fn is_dynamic_dimension_glyph(label: &str) -> bool { + label == crate::scene::parametric_constraints::DYNAMIC_DIMENSION_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 @@ -48,7 +53,11 @@ 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) || is_vertical_glyph(label) { + if label == "G²" + || is_fixed_glyph(label) + || is_vertical_glyph(label) + || is_dynamic_dimension_glyph(label) + { let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; return Size::new(side, side); } @@ -109,15 +118,10 @@ fn draw_concentric_constraint_glyph( fn draw_fixed_constraint_glyph( frame: &mut canvas::Frame, center: Point, - text: Color, + lock: Color, badge: Color, point_marker: bool, ) { - let lock = if point_marker { - text - } else { - Color::from_rgb8(214, 76, 76) - }; let shackle = canvas::Path::new(|builder| { let shackle_center = Point::new(center.x, center.y - 1.6); for step in 0..=12 { @@ -2114,7 +2118,10 @@ impl canvas::Program for SelectionCanvas { }) .into(), ); - frame.fill(&pill, bg); + // A dynamic dimension's lock sits bare on the drawing. + if !is_dynamic_dimension_glyph(label) { + frame.fill(&pill, bg); + } if *is_selected { frame.stroke( &pill, @@ -2132,14 +2139,18 @@ 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_dynamic_dimension_glyph(label) { + draw_fixed_constraint_glyph(&mut frame, glyph_center, fg, bg, false); } else if is_fixed_glyph(label) { - draw_fixed_constraint_glyph( - &mut frame, - glyph_center, - fg, - bg, - label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH, - ); + let point = + label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH; + // The object-mode lock reads red, the point-mode one white. + let lock = if point { + fg + } else { + Color::from_rgb8(214, 76, 76) + }; + draw_fixed_constraint_glyph(&mut frame, glyph_center, lock, bg, point); } else if is_vertical_glyph(label) { draw_vertical_constraint_glyph( &mut frame, From 0b5c6863718abc0c44daf832f38331c4f542e0ab Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:36:01 +0300 Subject: [PATCH 3/5] Dynamic dimensions: constant screen size, transform keeps the second constraint point Every dynamic dimension gets a DIMSCALE override for 12 px text, refreshed per zoom band, at creation and after load. A SCALE/ROTATE of an entity whose own end points a driving dimension measures now pins its second constraint point so the dimension moves the first end, as the reference does. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 1 + src/app/update/viewport.rs | 2 + src/scene/dwg_native_constraints.rs | 3 ++ src/scene/mod.rs | 4 ++ src/scene/parametric_constraints.rs | 60 +++++++++++++++++++++++++++++ src/scene/parametric_solve.rs | 9 +++-- 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1c435fd22..a1a093528 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3688,6 +3688,7 @@ impl OpenCADStudio { ); // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c5279e5a8..dee93d74c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -785,6 +785,8 @@ impl OpenCADStudio { pub(super) fn on_tick(&mut self, t: Instant) -> Task { let i = self.active_tab; self.tabs[i].scene.update(t - self.start); + // Dynamic dimensions keep their screen size across zoom bands. + self.tabs[i].scene.refresh_dynamic_dimension_scales(false); // If the camera moved since we last synced, write it back to // the document and mark the file dirty. diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dc8174bf5..f1e4a208d 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2810,6 +2810,9 @@ impl Scene { pub(crate) fn load_parametric_constraints_from_document(&mut self) { self.parametric_constraints.clear(); + // Dynamic dimensions read from the file get their screen size on the + // first tick after the view is known. + self.dynamic_dimension_band = 0.0; let owners: Vec<_> = self .document .block_records diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bf4dec17d..aae7b130d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2302,6 +2302,9 @@ pub struct Scene { pub dynamic_constraint_display: bool, /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. hidden_dynamic_dimensions: HashSet, + /// The zoom band (`quantize_wpp`) the dynamic dimensions were last + /// scaled for; 0 until the first scaling. + dynamic_dimension_band: f32, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2635,6 +2638,7 @@ impl Scene { constraint_name_format: 2, dynamic_constraint_display: true, hidden_dynamic_dimensions: HashSet::default(), + dynamic_dimension_band: 0.0, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index eb8e2c0f6..a34b1989b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -364,6 +364,9 @@ pub(crate) fn next_dimensional_parameter_name( /// The constraint-bar label that draws a dynamic dimension's lock mark. pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; +/// The on-screen text height of a dynamic dimension, in pixels. +pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, /// 1 = value, 2 = name=value. pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { @@ -1575,6 +1578,63 @@ impl super::Scene { .any(|set| set.dimensions.values().any(|dimension| *dimension == handle)) } + /// Gives every dynamic dimension a DIMSCALE override that keeps its + /// text, arrows and offsets at a screen size (the reference draws them + /// at a constant pixel size whatever the zoom). Runs once per zoom band; + /// `force` re-applies after a dimension was created or loaded. + pub fn refresh_dynamic_dimension_scales(&mut self, force: bool) { + let Some(band) = super::Scene::quantize_wpp(self.world_per_pixel()) else { + return; + }; + if !force && band == self.dynamic_dimension_band { + return; + } + self.dynamic_dimension_band = band; + let handles: Vec = self + .parametric_constraints + .iter() + .flat_map(|set| set.dimensions.values().copied()) + .collect(); + let mut changes = Vec::new(); + for handle in handles { + let Some(acadrust::EntityType::Dimension(dimension)) = self.document.get_entity(handle) + else { + continue; + }; + let style_name = dimension.base().style_name.clone(); + let text_height = self + .document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(&style_name) + || (style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) + .map(|style| style.dimtxt) + .filter(|height| *height > 1e-9) + .unwrap_or(0.18); + let scale = f64::from(band) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; + let current = crate::entities::dim_override::real( + &dimension.base().common.extended_data, + crate::entities::dim_override::DIMSCALE, + ); + if current.is_some_and(|value| (value - scale).abs() < 1e-9) { + continue; + } + crate::entities::dim_override::set( + &mut self.document, + handle, + crate::entities::dim_override::DIMSCALE, + Some(acadrust::xdata::XDataValue::Real(scale)), + ); + changes.push((handle, super::ChangeKind::Modified)); + } + if !changes.is_empty() { + self.bump_entities(&changes); + } + } + /// Re-derives which dynamic dimensions stay off screen (DCHIDE, or /// DYNCONSTRAINTDISPLAY 0 for all of them) and redraws the ones that /// changed. diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index c337bf2e7..3101a2103 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -2442,9 +2442,10 @@ fn solve_scope( }) .flatten(); // A transformed entity whose own end points a driving dimension - // measures keeps only the first constraint point where the transform - // put it; the dimension pulls the other end back to its value (a - // scaled line keeps its d1), as in the reference. + // measures keeps only its second constraint point where the transform + // put it; the dimension pulls the first end back to its value (a line + // scaled 2x about its start keeps its d1 by moving that start), as in + // the reference. let dimension_pin = (reference.marker.is_none() && axis_pin.is_none()) .then(|| { constraints.iter().find_map(|c| { @@ -2461,7 +2462,7 @@ fn solve_scope( && c.refs .iter() .all(|r| r.entity == reference.entity && r.marker.is_some())) - .then(|| c.refs[0]) + .then(|| c.refs[1]) }) }) .flatten(); From aa51765a62224eefe0af2ee650f374b594cb25b9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:57:05 +0300 Subject: [PATCH 4/5] Dynamic dimensions: reference text format, horizontal text, lock after the text A plain number shows as typed (d1=100), an expression under the fx: prefix (fx: d2=d1*2); the text stays horizontal through DIMTIH/DIMTOH overrides and the lock mark follows the text. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 1 + src/entities/dimension.rs | 12 ++---- src/scene/parametric_constraints.rs | 64 +++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a1a093528..84b3f3b6d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3611,6 +3611,7 @@ impl OpenCADStudio { }; let text = dynamic_dimension_text( &name, + &expression, value, self.tabs[i].scene.constraint_name_format, ); diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 906dea36d..6f7efb264 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,8 +6295,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } } -/// Where a dynamic dimension's lock mark sits: just before the text on its -/// baseline, with the direction that points away from the text. +/// Where a dynamic dimension's lock mark sits: just after the (horizontal) +/// text, with the direction that points away from the text. pub(crate) fn dynamic_dimension_lock_anchor( document: &CadDocument, dim: &Dimension, @@ -6317,13 +6317,7 @@ pub(crate) fn dynamic_dimension_lock_anchor( let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); let half_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH * 0.5; let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); - let rot = if dim.base().text_rotation.abs() > 1e-9 { - dim.base().text_rotation - } else { - dimension_text_natural_rotation(dim) - }; - let (sr, cr) = rot.sin_cos(); - let outward = Vector3::new(-cr, -sr, 0.0); + let outward = Vector3::new(1.0, 0.0, 0.0); Some(( Vector3::new( pos.x + outward.x * half_width, diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index a34b1989b..579eaa90b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -368,12 +368,23 @@ pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, -/// 1 = value, 2 = name=value. -pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { +/// 1 = value, 2 = name=value. A plain number shows as typed (`d1=100`); an +/// expression shows the expression under the reference's `fx:` prefix +/// (`fx: d2=d1*2`). +pub(crate) fn dynamic_dimension_text(name: &str, source: &str, value: f64, format: u8) -> String { + let source = source.trim(); + let literal = source.parse::().is_ok(); + let shown = if literal { + source.to_string() + } else { + measured_expression(value) + }; match format { 0 => name.to_string(), - 1 => format!("{value:.4}"), - _ => format!("{name}={value:.4}"), + 1 if literal => shown, + 1 => format!("fx: {shown}"), + _ if literal => format!("{name}={shown}"), + _ => format!("fx: {name}={source}"), } } @@ -1477,14 +1488,24 @@ impl super::Scene { let Some(constraint) = set.get(*id) else { continue; }; - let (name, value) = match &constraint.driving_param { - Some(DrivingValue::Named(name)) => { - (name.clone(), table.resolve(name).unwrap_or(f64::NAN)) + let (name, source, value) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => ( + name.clone(), + table + .get(name) + .map(|parameter| parameter.source.clone()) + .unwrap_or_default(), + table.resolve(name).unwrap_or(f64::NAN), + ), + Some(DrivingValue::Literal(value)) => { + (String::new(), measured_expression(*value), *value) } - Some(DrivingValue::Literal(value)) => (String::new(), *value), None => continue, }; - updates.push((*dimension, dynamic_dimension_text(&name, value, format))); + updates.push(( + *dimension, + dynamic_dimension_text(&name, &source, value, format), + )); } } for (handle, text) in updates { @@ -1615,19 +1636,30 @@ impl super::Scene { .filter(|height| *height > 1e-9) .unwrap_or(0.18); let scale = f64::from(band) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; - let current = crate::entities::dim_override::real( - &dimension.base().common.extended_data, - crate::entities::dim_override::DIMSCALE, - ); - if current.is_some_and(|value| (value - scale).abs() < 1e-9) { + use crate::entities::dim_override as ov; + let xdata = &dimension.base().common.extended_data; + let current = ov::real(xdata, ov::DIMSCALE); + // The reference draws dynamic dimension text horizontally. + let horizontal = ov::int(xdata, ov::DIMTIH) == Some(1) && ov::int(xdata, ov::DIMTOH) == Some(1); + if current.is_some_and(|value| (value - scale).abs() < 1e-9) && horizontal { continue; } - crate::entities::dim_override::set( + ov::set( &mut self.document, handle, - crate::entities::dim_override::DIMSCALE, + ov::DIMSCALE, Some(acadrust::xdata::XDataValue::Real(scale)), ); + if !horizontal { + for code in [ov::DIMTIH, ov::DIMTOH] { + ov::set( + &mut self.document, + handle, + code, + Some(acadrust::xdata::XDataValue::Integer16(1)), + ); + } + } changes.push((handle, super::ChangeKind::Modified)); } if !changes.is_empty() { From 3e28604752160135694fb5a222916ebc3d35c529 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:08:44 +0300 Subject: [PATCH 5/5] Dimensional constraints: DCFORM, DIMCONSTRAINT options, -PARAMETERS, value prompt, save without display overrides DCFORM sets the annotational or dynamic form and continues into the DIMCONSTRAINT option prompt, whose wording and last-used default match the reference. -PARAMETERS manages parameters from the command line; a double-clicked constraint dimension opens its value prompt. Dynamic dimensions rescale on every camera change and their display overrides stay out of the saved file. A driving dimension between an entity's own ends no longer has its length retained, so a new value moves the second point along the dimension axis; parameter edits hold the first constraint point. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 274 ++++++++++++++++++++++- src/app/commands/draw.rs | 138 +++++++++++- src/app/mod.rs | 6 + src/app/update/file.rs | 6 +- src/app/update/viewport.rs | 9 + src/modules/parametric/dim_constraint.rs | 155 ++++++++++++- src/modules/parametric/mod.rs | 9 +- src/modules/parametric/parameters_cli.rs | 146 ++++++++++++ src/scene/dwg_native_constraints.rs | 2 +- src/scene/mod.rs | 7 +- src/scene/parametric_constraints.rs | 77 ++++++- src/scene/parametric_solve.rs | 15 ++ 12 files changed, 810 insertions(+), 34 deletions(-) create mode 100644 src/modules/parametric/parameters_cli.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 84b3f3b6d..7e96ea001 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3,6 +3,28 @@ use crate::command::{CmdResult, SelectionEntity, StepInput}; use acadrust::Handle; use iced::Task; +/// `old` as a whole identifier in `source` becomes `new`. +fn replace_identifier(source: &str, old: &str, new: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut token = String::new(); + let flush = |token: &mut String, out: &mut String| { + if !token.is_empty() { + out.push_str(if token == old { new } else { token.as_str() }); + token.clear(); + } + }; + for c in source.chars() { + if c.is_alphanumeric() || c == '_' { + token.push(c); + } else { + flush(&mut token, &mut out); + out.push(c); + } + } + flush(&mut token, &mut out); + out +} + impl OpenCADStudio { pub(super) fn reject_locked_edit(&mut self, i: usize, handle: Handle) -> bool { let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else { @@ -1147,6 +1169,213 @@ impl OpenCADStudio { } } + /// `Dynamic` or `Annotational`, the DCFORM setting. + pub(crate) fn constraint_form_name(&self) -> &'static str { + if self.constraint_form_annotational { + "Annotational" + } else { + "Dynamic" + } + } + + /// The value prompt a double-clicked constraint dimension opens. + pub(crate) fn dynamic_dimension_value_command( + &self, + i: usize, + handle: Handle, + ) -> Option { + use crate::scene::named_parameters::DrivingValue; + let scene = &self.tabs[i].scene; + for set in &scene.parametric_constraints { + let Some((id, _)) = set + .dimensions + .iter() + .find(|(_, dimension)| **dimension == handle) + else { + continue; + }; + let constraint = set.get(*id)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + let table = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; + let current = table + .get(name) + .map(|parameter| parameter.source.trim().to_string()) + .unwrap_or_default(); + return Some(crate::modules::parametric::DimensionValueCommand::new( + name.clone(), + current, + )); + } + None + } + + /// Applies `expression` or `newname=expression` to parameter `name`: + /// the dimension value prompt and -PARAMETERS Edit. + pub(crate) fn apply_parameter_input(&mut self, i: usize, name: &str, input: &str) { + let input = input.trim(); + let (target, expression) = match input.split_once('=') { + Some((new_name, expression)) => { + (new_name.trim().to_string(), expression.trim().to_string()) + } + None => (name.to_string(), input.to_string()), + }; + if expression.is_empty() { + return; + } + if target != name { + if let Err(error) = self.rename_parameter(i, name, &target) { + self.command_line.push_error(&error); + return; + } + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&target, &expression) { + self.command_line.push_error(&error.to_string()); + return; + } + if !table + .resolve(&target) + .is_ok_and(|value| value.is_finite()) + { + self.command_line.push_error("Invalid expression."); + return; + } + let pending = self.begin_undo(i, "Edit parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.resolve_named_parameter_edit(i, &target); + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// -PARAMETERS New. + pub(crate) fn create_parameter(&mut self, i: usize, name: &str, expression: &str) { + if self.tabs[i].scene.named_parameters().contains(name) { + self.command_line + .push_error(&format!("Parameter {name} already exists.")); + return; + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(name, expression) { + self.command_line.push_error(&error.to_string()); + return; + } + let pending = self.begin_undo(i, "New parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// Renames a parameter everywhere: the table, the expressions that use + /// it and the constraints it drives. + pub(crate) fn rename_parameter(&mut self, i: usize, old: &str, new: &str) -> Result<(), String> { + use crate::scene::named_parameters::{is_reserved_name, is_valid_name, DrivingValue}; + let table = self.tabs[i].scene.named_parameters().clone(); + let Some(source) = table.get(old).map(|parameter| parameter.source.clone()) else { + return Err(format!("Parameter {old} not found.")); + }; + if !is_valid_name(new) || is_reserved_name(new) || table.contains(new) { + return Err(format!("Invalid parameter name {new}.")); + } + let mut renamed = table.clone(); + renamed.set(new, &source).map_err(|error| error.to_string())?; + for parameter in table.iter() { + if parameter.name == old { + continue; + } + let rewritten = replace_identifier(¶meter.source, old, new); + if rewritten != parameter.source { + renamed + .set(¶meter.name, &rewritten) + .map_err(|error| error.to_string())?; + } + } + renamed.remove(old); + let pending = self.begin_undo(i, "Rename parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = renamed; + let scopes: Vec<_> = self.tabs[i] + .scene + .parametric_constraints + .iter() + .map(|set| set.scope) + .collect(); + for scope in scopes { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .filter(|set| { + set.constraints.iter().any(|constraint| { + matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) + }) + }) + .cloned(); + let Some(before) = before else { + continue; + }; + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + for constraint in &mut set.constraints { + if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) { + constraint.driving_param = Some(DrivingValue::Named(new.to_string())); + } + } + } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + Ok(()) + } + + /// -PARAMETERS Delete: a parameter no constraint drives with. + pub(crate) fn delete_parameter(&mut self, i: usize, name: &str) { + use crate::scene::named_parameters::DrivingValue; + if !self.tabs[i].scene.named_parameters().contains(name) { + self.command_line + .push_error(&format!("Parameter {name} not found.")); + return; + } + let in_use = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .any(|constraint| { + matches!(&constraint.driving_param, Some(DrivingValue::Named(used)) if used == name) + }); + if in_use { + self.command_line + .push_error(&format!("Parameter {name} is used by a constraint.")); + return; + } + let pending = self.begin_undo(i, "Delete parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters_mut().remove(name); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + fn remove_parametric_constraint( &mut self, id: crate::scene::parametric_constraints::ConstraintId, @@ -1344,7 +1573,25 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + // A changed value moves a dimensional constraint's second point; its + // first point stays, as in the reference. + let anchors: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) + }) + .filter_map(|c| c.refs.first().copied()) + .filter(|reference| reference.marker.is_some()) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &anchors, + self.constraint_solve_mode, + ); } /// Commits one field of one Parameters-section row (Properties panel) — @@ -3609,9 +3856,17 @@ impl OpenCADStudio { return Task::none(); } }; + // DCFORM Annotational: an ordinary plotted dimension that shows + // the value at dimension precision. + let annotational = self.constraint_form_annotational; + let source = if annotational { + format!("{value:.4}") + } else { + expression.clone() + }; let text = dynamic_dimension_text( &name, - &expression, + &source, value, self.tabs[i].scene.constraint_name_format, ); @@ -3641,11 +3896,13 @@ impl OpenCADStudio { } // A dynamic dimension draws in the constraint gray, not the // current color. - entity.common_mut().color = acadrust::types::Color::Rgb { - r: 103, - g: 109, - b: 118, - }; + if !annotational { + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } let constraints_before = self.tabs[i] .scene .parametric_constraint_set(scope) @@ -3664,6 +3921,9 @@ impl OpenCADStudio { self.tabs[i].scene.record_undo_named_parameters_before(); self.tabs[i].scene.named_parameters = table; let dimension = self.tabs[i].scene.add_entity(entity); + if annotational { + self.tabs[i].scene.mark_dimension_annotational(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, dimension); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index cf9d4686d..aa6d28f11 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1310,6 +1310,7 @@ impl OpenCADStudio { } "DCCONVERT" => { + self.dim_constraint_last = "Convert"; let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1508,14 +1509,129 @@ impl OpenCADStudio { "DIMCONSTRAINT" => { use crate::modules::parametric::DimConstraintMenuCommand; - // Only the dynamic form exists here. - self.command_line - .push_output("Current settings: Constraint form = Dynamic"); - let new_cmd = DimConstraintMenuCommand; + self.command_line.push_output(&format!( + "Current settings: Constraint form = {}", + self.constraint_form_name() + )); + let new_cmd = DimConstraintMenuCommand::new(self.dim_constraint_last); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DCFORM" => { + use crate::modules::parametric::ConstraintFormCommand; + let new_cmd = ConstraintFormCommand::new(self.constraint_form_annotational); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + // DCFORM's answer, then on into DIMCONSTRAINT's options as the + // reference does. + cmd if cmd.starts_with("DCFORM_SET ") => { + use crate::modules::parametric::DimConstraintMenuCommand; + self.constraint_form_annotational = cmd + .trim_start_matches("DCFORM_SET ") + .trim() + .eq_ignore_ascii_case("Annotational"); + self.command_line.push_output(&format!( + "Current settings: Constraint form = {}", + self.constraint_form_name() + )); + let new_cmd = DimConstraintMenuCommand::new(self.dim_constraint_last); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + // A dynamic dimension's value prompt (double-click). + cmd if cmd.starts_with("DCVALUE ") => { + let rest = cmd.trim_start_matches("DCVALUE ").trim(); + if let Some((name, input)) = rest.split_once(' ') { + self.apply_parameter_input(i, name.trim(), input); + } + } + + "-PARAMETERS" => { + use crate::modules::parametric::ParametersCliCommand; + let new_cmd = ParametersCliCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + cmd if cmd.starts_with("-PARAMETERS ") => { + let rest = cmd.trim_start_matches("-PARAMETERS ").trim(); + let (op, args) = rest.split_once(' ').unwrap_or((rest, "")); + match op { + "LIST" => { + let rule = "-".repeat(74); + let rows: Vec = { + let table = self.tabs[i].scene.named_parameters(); + table + .iter() + .map(|parameter| { + let value = table + .resolve(¶meter.name) + .map(|value| format!("{value:.4}")) + .unwrap_or_else(|_| "**".to_string()); + format!( + "Parameter: {:<12} Expression: {:<21} Value: {value}", + parameter.name, parameter.source + ) + }) + .collect() + }; + self.command_line.push_output(&rule); + for row in rows { + self.command_line.push_output(&row); + } + self.command_line.push_output(&rule); + } + "NEW" => { + if let Some((name, expression)) = args.split_once(' ') { + self.create_parameter(i, name.trim(), expression.trim()); + } + } + "EDIT" => { + use crate::modules::parametric::ParametersCliCommand; + let name = args.trim().to_string(); + let old = { + let table = self.tabs[i].scene.named_parameters(); + table.get(&name).map(|parameter| { + let value = table + .resolve(&name) + .map(|value| format!("{value:.4}")) + .unwrap_or_else(|_| "**".to_string()); + format!("Old Expression = {}, Value = {value}", parameter.source) + }) + }; + match old { + Some(line) => { + self.command_line.push_output(&line); + let new_cmd = ParametersCliCommand::edit_expression(name); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + None => self + .command_line + .push_error(&format!("Parameter {name} not found.")), + } + } + "SET" => { + if let Some((name, expression)) = args.split_once(' ') { + self.apply_parameter_input(i, name.trim(), expression.trim()); + } + } + "RENAME" => { + if let Some((old, new)) = args.split_once(' ') { + if let Err(error) = self.rename_parameter(i, old.trim(), new.trim()) { + self.command_line.push_error(&error); + } + } + } + "DELETE" => self.delete_parameter(i, args.trim()), + _ => {} + } + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); @@ -1765,6 +1881,12 @@ impl OpenCADStudio { "DCALIGNED" => DimConstraintAxis::Aligned, _ => DimConstraintAxis::Linear, }; + self.dim_constraint_last = match cmd { + "DCHORIZONTAL" => "Horizontal", + "DCVERTICAL" => "Vertical", + "DCALIGNED" => "Aligned", + _ => "Linear", + }; let name = next_dimensional_parameter_name(self.tabs[i].scene.named_parameters()); // Both constraint points are picked inside the command. self.tabs[i].scene.deselect_all(); @@ -1774,6 +1896,11 @@ impl OpenCADStudio { } "DCONSTRAINT" | "DCRADIUS" | "DCDIAMETER" => { + if cmd == "DCRADIUS" { + self.dim_constraint_last = "Radius"; + } else if cmd == "DCDIAMETER" { + self.dim_constraint_last = "Diameter"; + } let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1813,6 +1940,9 @@ impl OpenCADStudio { } "ACONSTRAINT" | "DCANGULAR" => { + if cmd == "DCANGULAR" { + self.dim_constraint_last = "ANgular"; + } let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; diff --git a/src/app/mod.rs b/src/app/mod.rs index fd1c73551..674622972 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -612,6 +612,10 @@ pub(super) struct OpenCADStudio { pub constraint_solve_mode: bool, pub constraint_infer: bool, pub constraint_bar_display: i16, + /// DCFORM: new dimensional constraints use the annotational form. + pub constraint_form_annotational: bool, + /// The dimensional constraint DIMCONSTRAINT offers by default: the last one used. + pub dim_constraint_last: &'static str, pub constraint_bar_mode: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. @@ -3949,6 +3953,8 @@ impl OpenCADStudio { constraint_solve_mode: true, constraint_infer: false, constraint_bar_display: 3, + constraint_form_annotational: false, + dim_constraint_last: "Aligned", constraint_bar_mode: 4095, savetime_min: 10, default_bg_color: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bb43488b2..dd93a679d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1464,7 +1464,7 @@ impl OpenCADStudio { self.prepare_native_save(i); let version = self.tabs[i].scene.document.version; - let snapshot = self.tabs[i].scene.document.clone(); + let snapshot = self.tabs[i].scene.document_for_save(); crate::io::save_owned_as_version_atomic( snapshot, &path, @@ -1893,7 +1893,7 @@ impl OpenCADStudio { path: std::path::PathBuf, ) -> Task { let i = self.active_tab; - let document = self.tabs[i].scene.document.clone(); + let document = self.tabs[i].scene.document_for_save(); let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); let worker_name = block_name.clone(); let worker_path = path.clone(); @@ -2298,7 +2298,7 @@ impl OpenCADStudio { crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) }); let clone_started = iced::time::Instant::now(); - let mut snapshot = self.tabs[i].scene.document.clone(); + let mut snapshot = self.tabs[i].scene.document_for_save(); // Save-As across folders: rebase relative reference paths onto the // new base dir inside the snapshot only (live strings are untouched). if purpose == crate::app::SavePurpose::SaveAs { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index dee93d74c..4e9d2006e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5060,6 +5060,15 @@ properties={:.1}ms picked={}", { return Task::none(); } + // A constraint dimension edits its parameter from the + // command line, standing in for the reference's in-place + // value editor. + if let Some(prompt) = self.dynamic_dimension_value_command(i, handle) { + use crate::command::CadCommand; + self.command_line.push_info(&prompt.prompt()); + self.tabs[i].active_cmd = Some(Box::new(prompt)); + return Task::none(); + } // Any text-bearing entity opens its in-place editor // (plain box or rich MText editor, per type). A // Leader resolves to the entity it annotates. diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index 522119203..d3e5dadf4 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -392,9 +392,16 @@ impl CadCommand for DimConstraintCommand { /// `DIMCONSTRAINT`: the reference's option front end for the dimensional /// constraint family; each choice runs the focused command. -pub struct DimConstraintMenuCommand; +pub struct DimConstraintMenuCommand { + /// The option Enter takes: the dimensional constraint used last. + default: &'static str, +} impl DimConstraintMenuCommand { + pub fn new(default: &'static str) -> Self { + Self { default } + } + fn dispatch(keyword: &str) -> Option<&'static str> { Some(match keyword { "L" | "LINEAR" => "DCLINEAR", @@ -404,10 +411,24 @@ impl DimConstraintMenuCommand { "AN" | "ANGULAR" => "DCANGULAR", "R" | "RADIAL" | "RADIUS" => "DCRADIUS", "D" | "DIAMETER" => "DCDIAMETER", + "F" | "FORM" => "DCFORM", "C" | "CONVERT" => "DCCONVERT", _ => return None, }) } + + fn command_for_label(label: &str) -> &'static str { + match label { + "Linear" => "DCLINEAR", + "Horizontal" => "DCHORIZONTAL", + "Vertical" => "DCVERTICAL", + "ANgular" => "DCANGULAR", + "Radius" => "DCRADIUS", + "Diameter" => "DCDIAMETER", + "Convert" => "DCCONVERT", + _ => "DCALIGNED", + } + } } impl CadCommand for DimConstraintMenuCommand { @@ -416,7 +437,10 @@ impl CadCommand for DimConstraintMenuCommand { } fn prompt(&self) -> String { - "DIMCONSTRAINT Enter dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radial/Diameter/Convert] :".to_string() + format!( + "DIMCONSTRAINT Enter a dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radius/Diameter/Form/Convert] <{}>:", + self.default + ) } fn options(&self) -> Vec { @@ -426,8 +450,9 @@ impl CadCommand for DimConstraintMenuCommand { CmdOption::new("Vertical", "V"), CmdOption::new("Aligned", "A"), CmdOption::new("ANgular", "AN"), - CmdOption::new("Radial", "R"), + CmdOption::new("Radius", "R"), CmdOption::new("Diameter", "D"), + CmdOption::new("Form", "F"), CmdOption::new("Convert", "C"), ] } @@ -446,7 +471,129 @@ impl CadCommand for DimConstraintMenuCommand { } fn on_enter(&mut self) -> CmdResult { - CmdResult::Dispatch("DCALIGNED".to_string()) + CmdResult::Dispatch(Self::command_for_label(self.default).to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// `DCFORM`: `Enter constraint form [Annotational/Dynamic] :`; +/// the host records the answer and continues into DIMCONSTRAINT's option +/// prompt, as the reference does. +pub struct ConstraintFormCommand { + annotational: bool, +} + +impl ConstraintFormCommand { + pub fn new(annotational: bool) -> Self { + Self { annotational } + } + + fn current(&self) -> &'static str { + if self.annotational { + "Annotational" + } else { + "Dynamic" + } + } +} + +impl CadCommand for ConstraintFormCommand { + fn name(&self) -> &'static str { + "DCFORM" + } + + fn prompt(&self) -> String { + format!( + "DCFORM Enter constraint form [Annotational/Dynamic] <{}>:", + self.current() + ) + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Annotational", "A"), + CmdOption::new("Dynamic", "D"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + let form = match keyword.as_str() { + "A" | "ANNOTATIONAL" => "Annotational", + "D" | "DYNAMIC" => "Dynamic", + _ => return None, + }; + Some(CmdResult::Dispatch(format!("DCFORM_SET {form}"))) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch(format!("DCFORM_SET {}", self.current())) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// The value prompt a double-clicked dynamic dimension opens, standing in +/// for the reference's in-place editor; `name=expression` also renames. +pub struct DimensionValueCommand { + name: String, + current: String, +} + +impl DimensionValueCommand { + pub fn new(name: String, current: String) -> Self { + Self { name, current } + } +} + +impl CadCommand for DimensionValueCommand { + fn name(&self) -> &'static str { + "DCVALUE" + } + + fn prompt(&self) -> String { + format!( + "DCVALUE Enter value or name and value <{}={}>:", + self.name, self.current + ) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + (!text.is_empty()).then(|| CmdResult::Dispatch(format!("DCVALUE {} {text}", self.name))) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 6353d3672..c064008d9 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -5,6 +5,7 @@ mod coincident; mod concentric; mod dim_constraint; +mod parameters_cli; mod constraint_bar; mod equal_distance; #[path = "equal.rs"] @@ -26,7 +27,11 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; -pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand}; +pub use dim_constraint::{ + ConstraintFormCommand, DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand, + DimensionValueCommand, +}; +pub use parameters_cli::ParametersCliCommand; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; @@ -158,7 +163,7 @@ inventory::submit!(crate::command::CommandRegistration { names: &[ "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", - "DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", + "DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", "DCFORM", "-PARAMETERS", ] }); diff --git a/src/modules/parametric/parameters_cli.rs b/src/modules/parametric/parameters_cli.rs new file mode 100644 index 000000000..ad6e8986e --- /dev/null +++ b/src/modules/parametric/parameters_cli.rs @@ -0,0 +1,146 @@ +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +/// `-PARAMETERS`: the reference's command-line parameter manager. One run +/// performs one operation; the host answers the `LIST`, `NEW`, `EDIT`, +/// `SET`, `RENAME` and `DELETE` dispatches. +pub struct ParametersCliCommand { + step: Step, +} + +#[derive(Clone)] +enum Step { + /// `Enter a parameter option [New/Edit/Rename/Delete/?]:` + Option, + /// `Enter name for new user parameter:` + NewName, + /// `Enter expression:` + NewExpression { name: String }, + /// `Enter parameter name:` + EditName, + /// `Enter expression:` after the host printed the old expression. + EditExpression { name: String }, + /// `Enter old parameter name:` + RenameOld, + /// `Enter new parameter name:` + RenameNew { old: String }, + /// `Enter parameter name to delete:` + DeleteName, +} + +impl ParametersCliCommand { + pub fn new() -> Self { + Self { step: Step::Option } + } + + /// The expression prompt the host opens after printing the old expression. + pub fn edit_expression(name: String) -> Self { + Self { + step: Step::EditExpression { name }, + } + } +} + +impl Default for ParametersCliCommand { + fn default() -> Self { + Self::new() + } +} + +impl CadCommand for ParametersCliCommand { + fn name(&self) -> &'static str { + "-PARAMETERS" + } + + fn prompt(&self) -> String { + let text = match &self.step { + Step::Option => "Enter a parameter option [New/Edit/Rename/Delete/?]:", + Step::NewName => "Enter name for new user parameter:", + Step::NewExpression { .. } | Step::EditExpression { .. } => "Enter expression:", + Step::EditName => "Enter parameter name:", + Step::RenameOld => "Enter old parameter name:", + Step::RenameNew { .. } => "Enter new parameter name:", + Step::DeleteName => "Enter parameter name to delete:", + }; + format!("-PARAMETERS {text}") + } + + fn options(&self) -> Vec { + match self.step { + Step::Option => vec![ + CmdOption::new("New", "N"), + CmdOption::new("Edit", "E"), + CmdOption::new("Rename", "R"), + CmdOption::new("Delete", "D"), + CmdOption::new("?", "?"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + match self.step.clone() { + Step::Option => { + let keyword = text.trim_start_matches('_').to_ascii_uppercase(); + let next = match keyword.as_str() { + "N" | "NEW" => Step::NewName, + "E" | "EDIT" => Step::EditName, + "R" | "RENAME" => Step::RenameOld, + "D" | "DELETE" => Step::DeleteName, + "?" => return Some(CmdResult::Dispatch("-PARAMETERS LIST".to_string())), + _ => return None, + }; + self.step = next; + Some(CmdResult::NeedPoint) + } + Step::NewName => { + self.step = Step::NewExpression { + name: text.to_string(), + }; + Some(CmdResult::NeedPoint) + } + Step::NewExpression { name } => { + Some(CmdResult::Dispatch(format!("-PARAMETERS NEW {name} {text}"))) + } + Step::EditName => Some(CmdResult::Dispatch(format!("-PARAMETERS EDIT {text}"))), + Step::EditExpression { name } => { + Some(CmdResult::Dispatch(format!("-PARAMETERS SET {name} {text}"))) + } + Step::RenameOld => { + self.step = Step::RenameNew { + old: text.to_string(), + }; + Some(CmdResult::NeedPoint) + } + Step::RenameNew { old } => { + Some(CmdResult::Dispatch(format!("-PARAMETERS RENAME {old} {text}"))) + } + Step::DeleteName => Some(CmdResult::Dispatch(format!("-PARAMETERS DELETE {text}"))), + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index f1e4a208d..bf5901e94 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2812,7 +2812,7 @@ impl Scene { self.parametric_constraints.clear(); // Dynamic dimensions read from the file get their screen size on the // first tick after the view is known. - self.dynamic_dimension_band = 0.0; + self.dynamic_dimension_camera_gen = None; let owners: Vec<_> = self .document .block_records diff --git a/src/scene/mod.rs b/src/scene/mod.rs index aae7b130d..d3e6b5c95 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2302,9 +2302,8 @@ pub struct Scene { pub dynamic_constraint_display: bool, /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. hidden_dynamic_dimensions: HashSet, - /// The zoom band (`quantize_wpp`) the dynamic dimensions were last - /// scaled for; 0 until the first scaling. - dynamic_dimension_band: f32, + /// The camera generation the dynamic dimensions were last scaled for. + dynamic_dimension_camera_gen: Option, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2638,7 +2637,7 @@ impl Scene { constraint_name_format: 2, dynamic_constraint_display: true, hidden_dynamic_dimensions: HashSet::default(), - dynamic_dimension_band: 0.0, + dynamic_dimension_camera_gen: None, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 579eaa90b..6a72eb76b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -367,6 +367,9 @@ pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; /// The on-screen text height of a dynamic dimension, in pixels. pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; +/// The xdata application that marks an annotational constraint dimension. +pub const ANNOTATIONAL_DIMENSION_APP: &str = "OCS_CONSTRAINT_FORM"; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, /// 1 = value, 2 = name=value. A plain number shows as typed (`d1=100`); an /// expression shows the expression under the reference's `fx:` prefix @@ -1502,6 +1505,13 @@ impl super::Scene { } None => continue, }; + // An annotational dimension shows the value at dimension + // precision, as the reference does. + let source = if self.dimension_is_annotational(*dimension) { + format!("{value:.4}") + } else { + source + }; updates.push(( *dimension, dynamic_dimension_text(&name, &source, value, format), @@ -1592,25 +1602,69 @@ impl super::Scene { ids.len() } - /// True for a dimension that shows a dimensional constraint. + /// True for a dynamic dimension of a dimensional constraint (not an + /// annotational one, which is an ordinary plotted dimension). pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { self.parametric_constraints .iter() .any(|set| set.dimensions.values().any(|dimension| *dimension == handle)) + && !self.dimension_is_annotational(handle) + } + + /// True when a dimensional constraint's dimension uses the annotational + /// form: drawn with its style, plotted, never rescaled to the screen. + pub(crate) fn dimension_is_annotational(&self, handle: Handle) -> bool { + self.document.get_entity(handle).is_some_and(|entity| { + entity + .common() + .extended_data + .get_record(ANNOTATIONAL_DIMENSION_APP) + .is_some() + }) + } + + /// Marks a constraint dimension as annotational (DCFORM Annotational). + pub(crate) fn mark_dimension_annotational(&mut self, handle: Handle) { + crate::scene::view::dispatch::set_entity_xdata( + &mut self.document, + handle, + ANNOTATIONAL_DIMENSION_APP, + Some(vec![acadrust::xdata::XDataValue::String( + "ANNOTATIONAL".to_string(), + )]), + ); + } + + /// The document as written to a file: a dynamic dimension's screen-size + /// overrides are a display matter and stay out of the file. + pub(crate) fn document_for_save(&self) -> acadrust::CadDocument { + use crate::entities::dim_override as ov; + let mut document = self.document.clone(); + for set in &self.parametric_constraints { + for handle in set.dimensions.values().copied() { + if self.dimension_is_annotational(handle) { + continue; + } + for code in [ov::DIMSCALE, ov::DIMTIH, ov::DIMTOH] { + ov::set(&mut document, handle, code, None); + } + } + } + document } /// Gives every dynamic dimension a DIMSCALE override that keeps its /// text, arrows and offsets at a screen size (the reference draws them - /// at a constant pixel size whatever the zoom). Runs once per zoom band; - /// `force` re-applies after a dimension was created or loaded. + /// at a constant pixel size whatever the zoom). Runs whenever the camera + /// changed; `force` re-applies after a dimension was created or loaded. pub fn refresh_dynamic_dimension_scales(&mut self, force: bool) { - let Some(band) = super::Scene::quantize_wpp(self.world_per_pixel()) else { + let Some(wpp) = self.world_per_pixel() else { return; }; - if !force && band == self.dynamic_dimension_band { + if !force && self.dynamic_dimension_camera_gen == Some(self.camera_generation) { return; } - self.dynamic_dimension_band = band; + self.dynamic_dimension_camera_gen = Some(self.camera_generation); let handles: Vec = self .parametric_constraints .iter() @@ -1622,6 +1676,10 @@ impl super::Scene { else { continue; }; + // An annotational dimension keeps its style's size. + if self.dimension_is_annotational(handle) { + continue; + } let style_name = dimension.base().style_name.clone(); let text_height = self .document @@ -1635,7 +1693,7 @@ impl super::Scene { .map(|style| style.dimtxt) .filter(|height| *height > 1e-9) .unwrap_or(0.18); - let scale = f64::from(band) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; + let scale = f64::from(wpp) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; use crate::entities::dim_override as ov; let xdata = &dimension.base().common.extended_data; let current = ov::real(xdata, ov::DIMSCALE); @@ -1676,8 +1734,9 @@ impl super::Scene { .iter() .flat_map(|set| { set.dimensions.iter().filter_map(|(id, dimension)| { - (!self.dynamic_constraint_display - || !self.is_parametric_constraint_visible(set.scope, *id)) + (!self.dimension_is_annotational(*dimension) + && (!self.dynamic_constraint_display + || !self.is_parametric_constraint_visible(set.scope, *id))) .then_some(*dimension) }) }) diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 3101a2103..0f1f4e671 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -2766,6 +2766,21 @@ fn solve_scope( constraint.refs.get(2).is_some_and(|axis| axis.entity == *handle) }) || equal_followers.iter().any(|follower| follower.entity == *handle) + // A driving dimension between an entity's own ends is its size; + // a retained length would contradict a new value. + || constraints.iter().any(|c| { + c.enabled + && c.driving_param.is_some() + && matches!( + c.kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + ) + && c.refs.len() >= 2 + && c.refs.iter().all(|r| r.entity == *handle && r.marker.is_some()) + }) { continue; }