From 6969c197466e72c8e68a3230594f299ea30dae9c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:52:37 +0300 Subject: [PATCH 1/3] Aligned dimensional constraint: Point & line, 2Lines, value and property parity DCALIGNED opens with `[Object/Point & line/2Lines] `. Point & line asks `Specify constraint point or [Line] :` then `Select line:` (or the line first and `Select constraint point:`), measures perpendicular to the line from the constraint point to the line end nearest to it, and stores a directed distance with the line as direction reference. 2Lines asks `Select first line:` and `Select second line to make parallel:`, adds a Parallel constraint solved with the first line pinned, then measures on the solved geometry between the nearest ends. Unsupported line picks report the reference's message; the same measurement again reports `The constraint already exists on the selected objects.` and ends the command; an invalid expression reports both reference lines and asks again. The offered default and seeded expression carry twelve significant digits; a typed value moves the second point (or the other object) while the first point and its measured line stay, negative values keep their sign in the parameter and use their magnitude, zero folds the points together, and the dynamic dimension's origins follow every solve. The lock mark sits after the text, a selected dynamic dimension shows triangle grips at its points and the text square, and its Properties show the Constraint section (Name and Expression editable) plus Text rotation. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 230 ++++++++++++++-- src/app/properties.rs | 120 ++++++++ src/app/update/command.rs | 10 + src/command.rs | 20 ++ src/entities/dimension.rs | 12 +- src/modules/parametric/dim_constraint.rs | 332 +++++++++++++++++++++-- src/scene/parametric_constraints.rs | 179 +++++++++++- src/scene/parametric_solve.rs | 16 +- 8 files changed, 855 insertions(+), 64 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1c435fd22..29a1bbd02 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1324,7 +1324,8 @@ impl OpenCADStudio { /// table, so unlike that whole-table Apply this can cheaply scope the /// re-solve to just the entities that actually reference it. fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { - let touched: Vec = self.tabs[i] + let readers: Vec<&crate::scene::parametric_constraints::ParametricConstraint> = self + .tabs[i] .scene .parametric_constraints .iter() @@ -1333,8 +1334,24 @@ impl OpenCADStudio { c.enabled && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) }) + .collect(); + let touched: Vec = readers + .iter() .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); + // A changed value moves a dimensional constraint's second point; its + // first point (and the line it measures perpendicular to) stays, as + // in the reference. + let anchors: Vec = readers + .iter() + .flat_map(|c| { + crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &c.refs, + ) + }) + .filter(|reference| reference.marker.is_some()) + .collect(); self.tabs[i].dirty = true; if touched.is_empty() { self.tabs[i].scene.sync_native_parametric_graph(); @@ -1344,7 +1361,9 @@ 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, false); } /// Commits one field of one Parameters-section row (Properties panel) — @@ -1481,6 +1500,52 @@ impl OpenCADStudio { Task::none() } + /// Commits a dynamic dimension's Name or Expression row: the typed text + /// goes to the Parameters-section row of the constraint's parameter. + pub(super) fn on_dynamic_dimension_field_commit( + &mut self, + field: &'static str, + param_field: crate::ui::window::named_parameters::ParamField, + ) -> Task { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let handles = self.property_target_handles(i); + let name = handles.first().and_then(|handle| { + let (_, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, *handle)?; + match &constraint.driving_param { + Some(DrivingValue::Named(name)) => Some(name.clone()), + _ => None, + } + }); + let index = name.and_then(|name| { + self.tabs[i] + .scene + .named_parameters() + .iter() + .position(|parameter| parameter.name == name) + }); + let Some(index) = index else { + self.refresh_properties(); + return Task::none(); + }; + self.tabs[i] + .properties + .edit_buf + .insert(FieldKey::Param(index, param_field), typed); + self.on_prop_param_commit(index, param_field) + } + /// Removes Parameters-section row `index` immediately. Any /// constraint that referenced it gets the same "undefined reference" /// resolve-failure treatment a formula's own bad reference already gets @@ -3572,26 +3637,62 @@ impl OpenCADStudio { second_point, location, axis, + direction, 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}; + use crate::scene::parametric_constraints::{ + distance_direction_type, dynamic_dimension_text, ConstraintKind, + }; let scope = self.tabs[i].current_parametric_scope(); - let refs = vec![first, second]; + let mut refs = vec![first, second]; + refs.extend(direction); + // The same measurement between the same references is + // refused after the value, and the command ends. + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == kind + && c.refs.len() == refs.len() + && c.refs[..2].contains(&first) + && c.refs[..2].contains(&second) + && c.refs.get(2) == refs.get(2) + }) + }); + if duplicate { + self.command_line + .push_output("The constraint already exists on the selected objects."); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Task::none(); + } // A refused value keeps the prompt open, as the reference's - // editor does. + // editor does. A directed distance validates as the plain + // two-point distance it also is. + let (validate_kind, validate_refs) = if kind == ConstraintKind::DistanceDirected { + (ConstraintKind::Distance, &refs[..2]) + } else { + (kind, &refs[..]) + }; if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - kind, - &refs, + validate_kind, + validate_refs, Some(&DrivingValue::Literal(1.0)), ) { self.reprompt_active_command(i, message); return Task::none(); } + let anchors = crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &refs, + ); let name = name.trim().to_string(); if !is_valid_name(&name) { self.reprompt_active_command(i, "Invalid parameter name."); @@ -3605,7 +3706,11 @@ impl OpenCADStudio { let value = match table.resolve(&name) { Ok(value) if value.is_finite() => value, _ => { - self.reprompt_active_command(i, "Invalid expression."); + self.command_line.push_error("Invalid expression."); + self.reprompt_active_command( + i, + "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", + ); return Task::none(); } }; @@ -3653,8 +3758,10 @@ impl OpenCADStudio { crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); let mut touched = vec![first.entity]; - if second.entity != first.entity { - touched.push(second.entity); + for reference in [Some(second), direction].into_iter().flatten() { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } } let pending = self.begin_undo(i, label, touched.len() + 1, false); self.tabs[i] @@ -3665,6 +3772,12 @@ impl OpenCADStudio { 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))); + if direction.is_some() { + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.distance_direction_type = + distance_direction_type::PERPENDICULAR_TO_LINE; + } + } set.dimensions.insert(id, dimension); self.tabs[i].scene.note_parametric_constraint_applied( scope, @@ -3679,13 +3792,13 @@ impl OpenCADStudio { .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, - ); + // The first point (and a line it belongs to that the distance + // runs perpendicular to) stays; a value other than the + // measured one moves the second, as in the reference. Not + // `retain_size`: the typed value is the new length. + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); self.tabs[i].dirty = true; @@ -3696,6 +3809,89 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::MakeParallel { + first_line, + first_ends, + second_line, + second_ends, + } => { + use crate::scene::parametric_constraints::{resolve_point, ConstraintKind}; + let scope = self.tabs[i].current_parametric_scope(); + let refs = [first_line, second_line]; + if let Err(message) = + self.tabs[i] + .scene + .validate_parametric_constraint(ConstraintKind::Parallel, &refs, None) + { + return self.apply_cmd_result(CmdResult::ReportError(message.to_string())); + } + let already = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == ConstraintKind::Parallel + && c.refs.contains(&first_line) + && c.refs.contains(&second_line) + }) + }); + if !already { + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Parallel constraint", 1, true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(ConstraintKind::Parallel, refs.to_vec(), None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + // The first line stays; the second turns parallel to it. + self.tabs[i].scene.bump_entities_with_parametric_policy( + &[(second_line.entity, crate::scene::ChangeKind::Modified)], + &first_ends, + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + let ends = { + let document = &self.tabs[i].scene.document; + document.get_entity(second_line.entity).and_then(|entity| { + let world = |reference: crate::scene::parametric_constraints::ParametricRef| { + let point = resolve_point(entity, reference.marker?)?; + Some((reference, glam::DVec3::new(point.x, point.y, point.z))) + }; + Some([world(second_ends[0])?, world(second_ends[1])?]) + }) + }; + let Some(ends) = ends else { + return self.apply_cmd_result(CmdResult::ReportError( + crate::modules::parametric::DimConstraintCommand::NO_OBJECT.to_string(), + )); + }; + let result = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.accept_parallel_line(ends)); + return match result { + Some(result) => self.apply_cmd_result(result), + None => Task::none(), + }; + } CmdResult::AddFixedConstraint(pick) => { use crate::modules::parametric::FixConstraintCommand; use crate::scene::parametric_constraints::{ diff --git a/src/app/properties.rs b/src/app/properties.rs index b96b37e04..185a0ff2a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2292,6 +2292,28 @@ impl OpenCADStudio { } _ => entity_type_label(entity), }; + // A dynamic dimension shows only its constraint and text + // rotation, as the reference does. + let title = match crate::scene::parametric_constraints::dynamic_dimension_constraint( + &self.tabs[i].scene.parametric_constraints, + handle, + ) { + Some((set, constraint)) => { + sections = dynamic_dimension_sections( + &self.tabs[i].scene, + set, + constraint, + sections, + ); + match entity { + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Aligned(_), + ) => t!("Aligned Dimensional Constraint").into_owned(), + _ => t!("Linear Dimensional Constraint").into_owned(), + } + } + None => title, + }; ui::PropertiesPanel { choice_combos: sections .iter() @@ -2739,6 +2761,26 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } } } + if self.tabs[i].scene.is_dynamic_dimension(handle) { + // A dynamic dimension shows the reference's grips: a + // triangle at each constraint point aimed at the other + // one, and the text square — no dimension line grip. + let points: Vec = entity_grips + .iter() + .filter(|grip| grip.id <= 1) + .map(|grip| grip.world) + .collect(); + entity_grips.retain(|grip| grip.id != 2); + for grip in &mut entity_grips { + if grip.id <= 1 { + let other = points.get(1 - grip.id).copied().unwrap_or(grip.world); + let dir = (other - grip.world).normalize_or(glam::DVec3::X); + *grip = crate::entities::common::oriented_triangle_grip( + grip.id, grip.world, dir, + ); + } + } + } entity_grips.extend(crate::scene::model::solid_history::primitive_grips( &self.tabs[i].scene.document, handle, @@ -3572,6 +3614,84 @@ fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &st /// Replace a row's value with an arbitrary control (editable field, dropdown, /// colour picker …) rather than plain read-only text. +/// A dynamic dimension's Properties as the reference shows them: the +/// Constraint rows, then only the text rotation of the dimension itself. +fn dynamic_dimension_sections( + scene: &crate::scene::Scene, + set: &crate::scene::parametric_constraints::ParametricConstraintSet, + constraint: &crate::scene::parametric_constraints::ParametricConstraint, + sections: Vec, +) -> Vec { + use crate::scene::model::object::{PropSection, PropValue, Property}; + use crate::scene::named_parameters::DrivingValue; + let table = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; + let (name, expression, value) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => ( + name.clone(), + table + .iter() + .find(|parameter| ¶meter.name == name) + .map(|parameter| parameter.source.clone()) + .unwrap_or_default(), + table.resolve(name).ok(), + ), + Some(DrivingValue::Literal(value)) => (String::new(), format!("{value}"), Some(*value)), + None => (String::new(), String::new(), None), + }; + let row = |label: &str, field: &'static str, value: PropValue| Property { + label: label.to_string(), + field, + value, + }; + let text_rotation = sections + .iter() + .flat_map(|section| section.props.iter()) + .find(|property| property.field == "text_rotation") + .cloned(); + let mut result = vec![PropSection { + title: t!("Constraint").into_owned(), + props: vec![ + row( + t!("Constraint Form").as_ref(), + "dyn_constraint_form", + PropValue::ReadOnly("Dynamic".to_string()), + ), + row( + t!("Reference").as_ref(), + "dyn_constraint_reference", + PropValue::ReadOnly(t!("No").into_owned()), + ), + row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)), + row( + t!("Expression").as_ref(), + "dyn_constraint_expression", + PropValue::EditText(expression), + ), + row( + t!("Value").as_ref(), + "dyn_constraint_value", + PropValue::ReadOnly(value.map(|v| format!("{v:.4}")).unwrap_or_default()), + ), + row( + t!("Description").as_ref(), + "dyn_constraint_description", + PropValue::ReadOnly(String::new()), + ), + ], + }]; + if let Some(text_rotation) = text_rotation { + result.push(PropSection { + title: t!("Text").into_owned(), + props: vec![text_rotation], + }); + } + result +} + fn set_row_value( sections: &mut [crate::scene::model::object::PropSection], field: &str, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 94502699b..5e2a18016 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3259,6 +3259,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task { + use crate::ui::window::named_parameters::ParamField; + // A dynamic dimension's Name/Expression rows edit its parameter. + let dynamic_field = match field { + "dyn_constraint_name" => Some(ParamField::Name), + "dyn_constraint_expression" => Some(ParamField::Formula), + _ => None, + }; + if let Some(param_field) = dynamic_field { + return self.on_dynamic_dimension_field_commit(field, param_field); + } let i = self.active_tab; self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); diff --git a/src/command.rs b/src/command.rs index b1b9d48e9..bcd1f968f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1625,10 +1625,22 @@ pub enum CmdResult { second_point: DVec3, location: DVec3, axis: DVec3, + /// Aligned's Point & line / 2Lines: the line the distance is + /// measured perpendicular to. + direction: Option, name: String, expression: String, label: &'static str, }, + /// Aligned's 2Lines: makes `second_line` parallel to `first_line` (whose + /// ends stay put), then hands the second line's solved ends back through + /// `CadCommand::accept_parallel_line`. + MakeParallel { + first_line: crate::scene::parametric_constraints::ParametricRef, + first_ends: [crate::scene::parametric_constraints::ParametricRef; 2], + second_line: crate::scene::parametric_constraints::ParametricRef, + second_ends: [crate::scene::parametric_constraints::ParametricRef; 2], + }, /// Adds a point or object symmetry relation around a picked line. The /// first reference and axis remain fixed during initial placement. AddSymmetricConstraint { @@ -2482,6 +2494,14 @@ pub trait CadCommand: Send { CmdResult::NeedPoint } + /// The second line's ends after a `MakeParallel` solve. + fn accept_parallel_line( + &mut self, + _ends: [(crate::scene::parametric_constraints::ParametricRef, DVec3); 2], + ) -> 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/entities/dimension.rs b/src/entities/dimension.rs index 906dea36d..e55e4d92b 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,7 +6295,7 @@ 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 +/// Where a dynamic dimension's lock mark sits: just after the text on its /// baseline, with the direction that points away from the text. pub(crate) fn dynamic_dimension_lock_anchor( document: &CadDocument, @@ -6323,13 +6323,11 @@ pub(crate) fn dynamic_dimension_lock_anchor( dimension_text_natural_rotation(dim) }; let (sr, cr) = rot.sin_cos(); - let outward = Vector3::new(-cr, -sr, 0.0); + let outward = Vector3::new(cr, sr, 0.0); + // A glyph-sized gap keeps the lock clear of the last digit. + let reach = half_width + text_height * 0.9; Some(( - Vector3::new( - pos.x + outward.x * half_width, - pos.y + outward.y * half_width, - pos.z, - ), + Vector3::new(pos.x + outward.x * reach, pos.y + outward.y * reach, pos.z), outward, )) } diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index 522119203..a5c11d32e 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -16,6 +16,29 @@ pub enum DimConstraintAxis { Aligned, } +/// A picked line (a line entity or one polyline segment) an Aligned +/// constraint measures perpendicular to. +#[derive(Clone, Copy)] +struct LineTarget { + line: ParametricRef, + /// Unit direction from its start to its end. + dir: DVec3, + /// Its two ends and where they are. + ends: [(ParametricRef, DVec3); 2], +} + +impl LineTarget { + /// The end nearest to `point` — the end the reference measures from. + fn nearest_end(&self, point: DVec3) -> (ParametricRef, DVec3) { + let [a, b] = self.ends; + if (b.1 - point).length_squared() < (a.1 - point).length_squared() { + b + } else { + a + } + } +} + #[derive(Clone, Copy)] enum Step { /// `Specify first constraint point or [Object] :` @@ -27,12 +50,37 @@ enum Step { first: ParametricRef, first_point: DVec3, }, + /// Aligned's Point & line: `Specify constraint point or [Line] :` + PointLinePoint, + /// `Select line:` after the constraint point. + PointLineLine { + point: ParametricRef, + point_pos: DVec3, + }, + /// `Select line:` first (the Line option). + LineFirst, + /// `Select constraint point:` after the line. + LinePoint { line: LineTarget }, + /// Aligned's 2Lines: `Select first line:` + TwoLinesFirst, + /// `Select second line to make parallel:` + TwoLinesSecond { line: LineTarget }, + /// The host is making the second line parallel; `accept_parallel_line` + /// brings its solved ends. + TwoLinesParallel { + first: LineTarget, + second: LineTarget, + /// Where the second line was picked. + pick: DVec3, + }, /// `Specify dimension line location:` Location { first: ParametricRef, second: ParametricRef, first_point: DVec3, second_point: DVec3, + /// The line the distance is measured perpendicular to. + direction: Option, }, /// `Enter value or name and value :` Value { @@ -44,6 +92,7 @@ enum Step { axis: DVec3, kind: ConstraintKind, measured: f64, + direction: Option, }, } @@ -63,6 +112,7 @@ impl DimConstraintCommand { 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 const INVALID_LINE: &'static str = "Invalid selection for Aligned. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc."; pub fn new(axis: DimConstraintAxis, default_name: String) -> Self { Self { @@ -127,9 +177,72 @@ impl DimConstraintCommand { Some(DVec3::new(point.x, point.y, point.z)) } + /// The line a Point & line or 2Lines pick measures against: a line + /// entity as a whole, or the picked polyline segment. + fn line_target(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (line, first, second) = match entity { + EntityType::Line(_) => ( + ParametricRef::whole(handle), + 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; + if index < 0 { + return None; + } + let end = if resolve_point(entity, index + 1).is_some() { + index + 1 + } else { + 0 + }; + ( + ParametricRef::segment(handle, index as usize), + ParametricRef::point(handle, index), + ParametricRef::point(handle, end), + ) + } + _ => return None, + }; + let start = Self::world(entity, first)?; + let finish = Self::world(entity, second)?; + let dir = (finish - start).try_normalize()?; + Some(LineTarget { + line, + dir, + ends: [(first, start), (second, finish)], + }) + } + + /// A picked line for the current line step, or the message to show. + fn pick_line(&mut self, handle: Handle, point: DVec3) -> Result { + if handle.is_null() { + return Err(Self::NO_OBJECT); + } + let Some(entity) = self.picked_entity.take() else { + return Err(""); + }; + Self::line_target(&entity, handle, point).ok_or(Self::INVALID_LINE) + } + /// 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) { + /// the way a linear dimension does, the others are fixed; a Point & line + /// or 2Lines distance runs perpendicular to its line. + fn decide( + &self, + first: DVec3, + second: DVec3, + location: DVec3, + direction: Option, + ) -> (ConstraintKind, DVec3) { + if let Some(line) = direction { + return ( + ConstraintKind::DistanceDirected, + DVec3::new(line.dir.y, -line.dir.x, 0.0), + ); + } match self.axis { DimConstraintAxis::Linear => { let axis = @@ -149,10 +262,11 @@ impl DimConstraintCommand { } } - fn measured(kind: ConstraintKind, first: DVec3, second: DVec3) -> f64 { + fn measured(kind: ConstraintKind, first: DVec3, second: DVec3, axis: DVec3) -> f64 { match kind { ConstraintKind::DistanceX => (second.x - first.x).abs(), ConstraintKind::DistanceY => (second.y - first.y).abs(), + ConstraintKind::DistanceDirected => (second - first).dot(axis).abs(), _ => (second - first).length(), } } @@ -166,6 +280,7 @@ impl DimConstraintCommand { location, axis, kind, + direction, .. } = self.step else { @@ -182,6 +297,7 @@ impl DimConstraintCommand { second_point, location, axis, + direction: direction.map(|line| line.line), name, expression, label: match self.axis { @@ -202,11 +318,23 @@ impl CadCommand for DimConstraintCommand { fn prompt(&self) -> String { let name = self.command_name(); match self.step { + Step::First if self.axis == DimConstraintAxis::Aligned => format!( + "{name} Specify first constraint point or [Object/Point & line/2Lines] :" + ), 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::PointLinePoint => { + format!("{name} Specify constraint point or [Line] :") + } + Step::PointLineLine { .. } | Step::LineFirst => format!("{name} Select line:"), + Step::LinePoint { .. } => format!("{name} Select constraint point:"), + Step::TwoLinesFirst => format!("{name} Select first line:"), + Step::TwoLinesSecond { .. } | Step::TwoLinesParallel { .. } => { + format!("{name} Select second line to make parallel:") + } Step::Location { .. } => format!("{name} Specify dimension line location:"), Step::Value { measured, .. } => format!( "{name} Enter value or name and value <{}={}>:", @@ -217,9 +345,16 @@ impl CadCommand for DimConstraintCommand { } fn options(&self) -> Vec { - matches!(self.step, Step::First) - .then(|| vec![CmdOption::new("Object", "O")]) - .unwrap_or_default() + match self.step { + Step::First if self.axis == DimConstraintAxis::Aligned => vec![ + CmdOption::new("Object", "O"), + CmdOption::new("Point & line", "P"), + CmdOption::new("2Lines", "2L"), + ], + Step::First => vec![CmdOption::new("Object", "O")], + Step::PointLinePoint => vec![CmdOption::new("Line", "L")], + _ => Vec::new(), + } } fn wants_text_input(&self) -> bool { @@ -227,15 +362,31 @@ impl CadCommand for DimConstraintCommand { } fn point_step_accepts_keywords(&self) -> bool { - matches!(self.step, Step::First | Step::Value { .. }) + matches!( + self.step, + Step::First | Step::PointLinePoint | 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; + let aligned = self.axis == DimConstraintAxis::Aligned; + match keyword.as_str() { + "O" | "OBJECT" => self.step = Step::Object, + "P" | "POINT" | "POINT & LINE" | "POINT&LINE" if aligned => { + self.step = Step::PointLinePoint + } + "2L" | "2LINES" if aligned => self.step = Step::TwoLinesFirst, + _ => return None, + } + Some(CmdResult::NeedPoint) + } + Step::PointLinePoint => { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(keyword.as_str(), "L" | "LINE") { + self.step = Step::LineFirst; return Some(CmdResult::NeedPoint); } None @@ -253,7 +404,18 @@ impl CadCommand for DimConstraintCommand { } fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::First | Step::Object | Step::Second { .. }) + matches!( + self.step, + Step::First + | Step::Object + | Step::Second { .. } + | Step::PointLinePoint + | Step::PointLineLine { .. } + | Step::LineFirst + | Step::LinePoint { .. } + | Step::TwoLinesFirst + | Step::TwoLinesSecond { .. } + ) } fn entity_pick_accepts_points(&self) -> bool { @@ -280,11 +442,72 @@ impl CadCommand for DimConstraintCommand { 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::First | Step::Second { .. } | Step::PointLinePoint | Step::LinePoint { .. } => { + CmdResult::CheckConstraintPoint(CoincidentPick { + handle: (!handle.is_null()).then_some(handle), + point, + whole_curve: false, + }) + } + Step::PointLineLine { point: first, point_pos } => { + match self.pick_line(handle, point) { + Ok(line) => { + if line.line.entity == first.entity { + return Self::report(Self::SAME_POINT); + } + let (second, second_point) = line.nearest_end(point_pos); + self.step = Step::Location { + first, + second, + first_point: point_pos, + second_point, + direction: Some(line), + }; + CmdResult::NeedPoint + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + } + } + Step::LineFirst => match self.pick_line(handle, point) { + Ok(line) => { + self.step = Step::LinePoint { line }; + CmdResult::NeedPoint + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + }, + Step::TwoLinesFirst => match self.pick_line(handle, point) { + Ok(line) => { + self.step = Step::TwoLinesSecond { line }; + CmdResult::NeedPoint + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + }, + Step::TwoLinesSecond { line: first_line } => match self.pick_line(handle, point) { + Ok(second_line) => { + if second_line.line == first_line.line { + return Self::report(Self::SAME_POINT); + } + // The host makes the second line parallel first; the + // distance is measured on the solved geometry. + self.step = Step::TwoLinesParallel { + first: first_line, + second: second_line, + pick: point, + }; + CmdResult::MakeParallel { + first_line: first_line.line, + first_ends: [first_line.ends[0].0, first_line.ends[1].0], + second_line: second_line.line, + second_ends: [second_line.ends[0].0, second_line.ends[1].0], + } + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + }, + Step::TwoLinesParallel { .. } => CmdResult::NeedPoint, Step::Object => { if handle.is_null() { return Self::report(Self::NO_OBJECT); @@ -308,6 +531,7 @@ impl CadCommand for DimConstraintCommand { second, first_point, second_point, + direction: None, }; CmdResult::NeedPoint } @@ -315,6 +539,32 @@ impl CadCommand for DimConstraintCommand { } } + fn accept_parallel_line(&mut self, ends: [(ParametricRef, DVec3); 2]) -> CmdResult { + let Step::TwoLinesParallel { first, second, pick } = self.step else { + return CmdResult::NeedPoint; + }; + let Some(dir) = (ends[1].1 - ends[0].1).try_normalize() else { + return Self::report(Self::NO_POINT); + }; + let second = LineTarget { + line: second.line, + dir, + ends, + }; + // The first line's end nearest the second pick, then the second + // line's end nearest that end. + let (first_ref, first_point) = first.nearest_end(pick); + let (second_ref, second_point) = second.nearest_end(first_point); + self.step = Step::Location { + first: first_ref, + second: second_ref, + first_point, + second_point, + direction: Some(first), + }; + CmdResult::NeedPoint + } + fn accept_constraint_point(&mut self, reference: ParametricRef, point: DVec3) -> CmdResult { match self.step { Step::First => { @@ -333,6 +583,28 @@ impl CadCommand for DimConstraintCommand { second: reference, first_point, second_point: point, + direction: None, + }; + CmdResult::NeedPoint + } + Step::PointLinePoint => { + self.step = Step::PointLineLine { + point: reference, + point_pos: point, + }; + CmdResult::NeedPoint + } + Step::LinePoint { line } => { + if line.line.entity == reference.entity { + return Self::report(Self::SAME_POINT); + } + let (first, first_point) = line.nearest_end(point); + self.step = Step::Location { + first, + second: reference, + first_point, + second_point: point, + direction: Some(line), }; CmdResult::NeedPoint } @@ -342,20 +614,27 @@ impl CadCommand for DimConstraintCommand { 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::First | Step::Second { .. } | Step::PointLinePoint | Step::LinePoint { .. } => { + CmdResult::CheckConstraintPoint(CoincidentPick { + handle: None, + point, + whole_curve: false, + }) + } + Step::Object + | Step::PointLineLine { .. } + | Step::LineFirst + | Step::TwoLinesFirst + | Step::TwoLinesSecond { .. } => Self::report(Self::NO_OBJECT), Step::Location { first, second, first_point, second_point, + direction, } => { - let (kind, axis) = self.decide(first_point, second_point, point); - let measured = Self::measured(kind, first_point, second_point); + let (kind, axis) = self.decide(first_point, second_point, point, direction); + let measured = Self::measured(kind, first_point, second_point, axis); self.step = Step::Value { first, second, @@ -365,10 +644,11 @@ impl CadCommand for DimConstraintCommand { axis, kind, measured, + direction, }; CmdResult::ReportMeasurement(format!("Dimension text = {measured:.4}")) } - Step::Value { .. } => CmdResult::NeedPoint, + Step::TwoLinesParallel { .. } | Step::Value { .. } => CmdResult::NeedPoint, } } @@ -378,6 +658,10 @@ impl CadCommand for DimConstraintCommand { self.step = Step::Object; CmdResult::NeedPoint } + Step::PointLinePoint => { + self.step = Step::LineFirst; + CmdResult::NeedPoint + } Step::Value { measured, .. } => self .build(self.default_name.clone(), measured_expression(measured)) .unwrap_or(CmdResult::Cancel), diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index eb8e2c0f6..1dd7f4b3d 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -374,17 +374,145 @@ pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> Stri } } -/// The expression a measured value seeds a parameter with (`100`, `111.8034`). +/// The expression a measured value seeds a parameter with: twelve +/// significant digits with the trailing zeros dropped (`100`, +/// `111.803398875`), as the reference stores it. 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 == "-" { + if !value.is_finite() { + return "0".to_string(); + } + // Solver noise (24.9999999047) reads as the round value it means. + let snapped = (value * 1.0e4).round() / 1.0e4; + let value = if (value - snapped).abs() < 1.0e-6 { + snapped + } else { + value + }; + let magnitude = value.abs(); + let integer_digits = if magnitude < 1.0 { + 1 + } else { + magnitude.log10().floor() as i32 + 1 + }; + let decimals = (12 - integer_digits).clamp(0, 12) as usize; + let text = format!("{value:.decimals$}"); + let text = if text.contains('.') { + text.trim_end_matches('0').trim_end_matches('.') + } else { + text.as_str() + }; + if text.is_empty() || text == "-" || text == "-0" { "0".to_string() } else { text.to_string() } } +/// Moves a dynamic dimension's extension origins to `first`/`second`, +/// keeping its dimension line where it was; true when anything moved. +fn dynamic_dimension_follow_points( + dimension: &mut acadrust::entities::Dimension, + first: Vector3, + second: Vector3, +) -> bool { + use acadrust::entities::Dimension; + let same = |a: Vector3, b: Vector3| (a - b).length_squared() < 1.0e-16; + let to_dvec = |p: Vector3| glam::DVec3::new(p.x, p.y, p.z); + let (current_first, current_second, definition, axis) = match dimension { + Dimension::Aligned(d) => (d.first_point, d.second_point, d.definition_point, None), + Dimension::Linear(d) => ( + d.first_point, + d.second_point, + d.definition_point, + Some(glam::DVec3::new(d.rotation.cos(), d.rotation.sin(), 0.0)), + ), + _ => return false, + }; + if same(current_first, first) && same(current_second, second) { + return false; + } + let text = dimension.base().user_text.clone(); + let rebuilt = match axis { + None => crate::modules::annotate::aligned_dim::aligned_dimension_entity( + to_dvec(first), + to_dvec(second), + to_dvec(definition), + text, + ), + Some(axis) => crate::modules::annotate::linear_dim::linear_dimension_entity( + to_dvec(first), + to_dvec(second), + to_dvec(definition), + axis, + text, + ), + }; + let acadrust::EntityType::Dimension(mut rebuilt) = rebuilt else { + return false; + }; + // Everything but the geometry stays: identity, layer, color, style, xdata. + let base = dimension.base(); + let target = rebuilt.base_mut(); + target.common = base.common.clone(); + target.style_name = base.style_name.clone(); + target.text_rotation = base.text_rotation; + target.attachment_point = base.attachment_point; + target.line_spacing_style = base.line_spacing_style; + target.line_spacing_factor = base.line_spacing_factor; + *dimension = rebuilt; + true +} + +/// What stays put when a dimensional constraint's value changes: its first +/// point, and — when the distance runs perpendicular to a line that owns +/// that point (2Lines, line-first Point & line) — the whole line, so the +/// other object moves, as in the reference. +pub(crate) fn dimensional_anchor_refs( + document: &acadrust::CadDocument, + refs: &[ParametricRef], +) -> Vec { + let Some(&first) = refs.first() else { + return Vec::new(); + }; + let mut anchors = vec![first]; + if let Some(&line) = refs.get(2).filter(|line| line.entity == first.entity) { + let ends = match line.segment_index() { + Some(index) => { + let index = index as i32; + let end = document + .get_entity(line.entity) + .and_then(|entity| resolve_point(entity, index + 1)) + .map_or(0, |_| index + 1); + [ + ParametricRef::point(line.entity, index), + ParametricRef::point(line.entity, end), + ] + } + None => [ + ParametricRef::point(line.entity, 0), + ParametricRef::point(line.entity, 1), + ], + }; + anchors.extend(ends.into_iter().filter(|end| *end != first)); + } + anchors +} + +/// The constraint a dynamic dimension shows, with the table its parameter +/// lives in. +pub(crate) fn dynamic_dimension_constraint( + sets: &[ParametricConstraintSet], + handle: Handle, +) -> Option<(&ParametricConstraintSet, &ParametricConstraint)> { + sets.iter().find_map(|set| { + let id = set + .dimensions + .iter() + .find_map(|(id, dimension)| (*dimension == handle).then_some(*id))?; + Some((set, set.get(id)?)) + }) +} + /// 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( @@ -1460,10 +1588,11 @@ impl super::Scene { /// Rewrites every dynamic dimension's text from its constraint's /// parameter name and current value, in the CONSTRAINTNAMEFORMAT - /// reading `constraint_name_format` selects. + /// reading `constraint_name_format` selects, and moves its extension + /// origins to where the solve left the constraint points. pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { let format = self.constraint_name_format; - let mut updates: Vec<(Handle, String)> = Vec::new(); + let mut updates: Vec<(Handle, String, Option<(Vector3, Vector3)>)> = Vec::new(); for set in &self.parametric_constraints { let table = if set.local_parameters.is_empty() { &self.named_parameters @@ -1481,23 +1610,43 @@ impl super::Scene { Some(DrivingValue::Literal(value)) => (String::new(), *value), None => continue, }; - updates.push((*dimension, dynamic_dimension_text(&name, value, format))); + let world = |reference: &ParametricRef| { + let entity = self.document.get_entity(reference.entity)?; + resolve_point(entity, reference.marker?) + }; + let points = match constraint.refs.as_slice() { + [first, second, ..] => world(first).zip(world(second)), + _ => None, + }; + updates.push(( + *dimension, + dynamic_dimension_text(&name, value, format), + points, + )); } } - for (handle, text) in updates { + for (handle, text, points) 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; + let mut changed = false; + if let Some((first, second)) = points { + if dynamic_dimension_follow_points(&mut dimension, first, second) { + changed = true; + } + } + if dimension.base().user_text.as_deref() != Some(text.as_str()) { + crate::entities::dimension::set_dimension_text_override( + dimension.base_mut(), + Some(text), + ); + changed = true; + } + if changed { + self.update_entity(acadrust::EntityType::Dimension(dimension)); } - crate::entities::dimension::set_dimension_text_override( - dimension.base_mut(), - Some(text), - ); - self.update_entity(acadrust::EntityType::Dimension(dimension)); } } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index c337bf2e7..61753cb4a 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -993,9 +993,15 @@ fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) - if !value.is_finite() { return None; } + // A dimensional distance keeps its sign in the parameter but measures + // its magnitude, as the reference does (`d1=-50` shortens the line to + // 50; `0` folds the points together). + if constraint.kind == ConstraintKind::Distance { + return Some(value.abs()); + } let must_be_positive = matches!( constraint.kind, - ConstraintKind::Distance | ConstraintKind::Radius | ConstraintKind::Diameter + ConstraintKind::Radius | ConstraintKind::Diameter ); (!must_be_positive || value > 0.0).then_some(value) } @@ -1590,6 +1596,14 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; + // A zero distance folds the points together; the distance + // residual has no gradient there, the coordinate equalities do. + if resolved.abs() <= f64::EPSILON { + return vec![ + Rc::new(Equal::new(pa.x, pb.x, 1.0)), + Rc::new(Equal::new(pa.y, pb.y, 1.0)), + ]; + } let target = sys.add_param(resolved, true); vec![Rc::new(P2PDistance::new(pa, pb, target))] } From 5fab97a7d0067c55ef4e7e7e9f833a9c4f6c7cd0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:04:26 +0300 Subject: [PATCH 2/3] Aligned constraint: axis lines, zero collapse, Properties form/reference/description, screen-sized text Point & line and 2Lines take a text baseline or an ellipse axis as the line; a zero value folds the points onto the first one; 2Lines turns the second line about its start. The Properties Constraint section edits Constraint Form (Dynamic on the *ADSK_CONSTRAINTS layer, Annotational on the current layer), Reference (driven constraint in parentheses whose parameter follows the measurement) and Description (stored with the parameter and the native variable). Dynamic dimensions keep a 12 px text and 8 px arrows at any zoom, drop trailing zeros and show formulas as `fx: len=d1*2`; the hidden constraints layer stays out of the layer lists. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 182 +++++++++++++++++++++-- src/app/layers.rs | 2 + src/app/properties.rs | 48 ++++-- src/app/update/command.rs | 6 + src/app/update/viewport.rs | 2 + src/modules/parametric/dim_constraint.rs | 36 +++++ src/scene/dwg_native_constraints.rs | 21 ++- src/scene/entity.rs | 2 +- src/scene/named_parameters.rs | 21 +++ src/scene/parametric_constraints.rs | 167 +++++++++++++++++++-- src/scene/parametric_solve.rs | 32 +++- src/ui/window/layers.rs | 2 + 12 files changed, 476 insertions(+), 45 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 29a1bbd02..b444ffd3b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1432,6 +1432,11 @@ impl OpenCADStudio { current.name.clone(), ), ParamField::Name => { + let description = self.tabs[i] + .scene + .named_parameters() + .description(¤t.name) + .to_string(); self.tabs[i] .scene .named_parameters_mut() @@ -1440,12 +1445,19 @@ impl OpenCADStudio { .scene .named_parameters_mut() .set(&typed, ¤t.source); - if outcome.is_err() { + let kept = if outcome.is_err() { let _ = self.tabs[i] .scene .named_parameters_mut() .set(¤t.name, ¤t.source); - } + current.name.as_str() + } else { + typed.as_str() + }; + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(kept, &description); (outcome, typed.clone()) } }; @@ -1500,6 +1512,133 @@ impl OpenCADStudio { Task::none() } + /// The dynamic dimension the Properties panel targets: its handle, scope, + /// constraint id and parameter name. + fn dynamic_dimension_target( + &self, + i: usize, + ) -> Option<( + Handle, + crate::scene::parametric_constraints::ParametricScope, + crate::scene::parametric_constraints::ConstraintId, + String, + )> { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + let handle = *self.property_target_handles(i).first()?; + let (set, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, handle)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + Some((handle, set.scope, constraint.id, name.clone())) + } + + /// Commits a dynamic dimension's Description row into its parameter. + pub(super) fn on_dynamic_dimension_description_commit( + &mut self, + field: &'static str, + ) -> Task { + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let Some((_, _, _, name)) = self.dynamic_dimension_target(i) else { + self.refresh_properties(); + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint description", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(&name, &typed); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// Applies a dynamic dimension's Constraint Form or Reference choice. + pub(super) fn on_dynamic_dimension_choice( + &mut self, + field: &'static str, + value: &str, + ) -> Task { + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; + let i = self.active_tab; + let Some((handle, scope, id, _)) = self.dynamic_dimension_target(i) else { + return Task::none(); + }; + match field { + "dyn_constraint_form" => { + // Annotational: an ordinary dimension on the current layer + // that plots; Dynamic: the gray one on the constraints layer. + let annotational = value.eq_ignore_ascii_case("Annotational"); + let active_layer = self.tabs[i].active_layer.clone(); + if !annotational { + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + } + self.apply_property_op(i, "Constraint form", &[handle], |app, handle| { + let Some(mut entity) = app.tabs[i].scene.document.get_entity(handle).cloned() + else { + return; + }; + if annotational { + entity.as_entity_mut().set_layer(active_layer.clone()); + entity.common_mut().color = acadrust::types::Color::ByLayer; + } else { + entity + .as_entity_mut() + .set_layer(DYNAMIC_DIMENSION_LAYER.to_string()); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + app.tabs[i].scene.update_entity(entity); + }); + } + "dyn_constraint_reference" => { + // A reference constraint reads the geometry instead of + // driving it; its text sits in parentheses. + let reference = + value.eq_ignore_ascii_case("Yes") || value == crate::t!("Yes").as_ref(); + let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() + else { + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint reference", 0, true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + self.tabs[i].scene.record_undo_named_parameters_before(); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.enabled = !reference; + } + 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); + } + self.refresh_properties(); + } + _ => {} + } + Task::none() + } + /// Commits a dynamic dimension's Name or Expression row: the typed text /// goes to the Parameters-section row of the constraint's parameter. pub(super) fn on_dynamic_dimension_field_commit( @@ -3718,6 +3857,8 @@ impl OpenCADStudio { &name, value, self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), ); let mut entity = if kind == ConstraintKind::Distance { aligned_dim::aligned_dimension_entity( @@ -3739,10 +3880,12 @@ impl OpenCADStudio { &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 lives on the reference's constraints + // layer, hidden from the layer lists and never plotted. + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); // A dynamic dimension draws in the constraint gray, not the // current color. entity.common_mut().color = acadrust::types::Color::Rgb { @@ -3857,10 +4000,13 @@ impl OpenCADStudio { id, self.constraint_bar_display, ); - // The first line stays; the second turns parallel to it. + // The first line stays; the second turns about its start + // to run parallel, as the reference keeps that start. + let mut pins = first_ends.to_vec(); + pins.push(second_ends[0]); self.tabs[i].scene.bump_entities_with_parametric_policy( &[(second_line.entity, crate::scene::ChangeKind::Modified)], - &first_ends, + &pins, self.constraint_solve_mode, ); self.tabs[i].dirty = true; @@ -8299,10 +8445,22 @@ fn entity_at_typed_point( extent = extent .max((bounds.max.x - bounds.min.x).abs()) .max((bounds.max.y - bounds.min.y).abs()); - let Some(distance) = - crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) - else { - continue; + let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { + Some(distance) => distance, + // A text or an ellipse answers by its extent, the way a click + // inside it picks it. + None if matches!( + entity, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Ellipse(_) + ) => + { + let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0); + let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0); + dx.hypot(dy) + } + None => continue, }; if nearest.is_none_or(|(best, _)| distance < best) { nearest = Some((distance, common.handle)); diff --git a/src/app/layers.rs b/src/app/layers.rs index 6be69c8f3..24792f24c 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -62,6 +62,8 @@ impl OpenCADStudio { .layers .layers .iter() + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|l| !l.name.starts_with('*')) .map(|l| crate::ui::ribbon::LayerInfo { name: l.name.clone(), color: crate::ui::window::layers::iced_color_from_acad(&l.color), diff --git a/src/app/properties.rs b/src/app/properties.rs index 185a0ff2a..edc43673f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -79,6 +79,8 @@ impl OpenCADStudio { .layers .iter() .map(|l| l.name.clone()) + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|name| !name.starts_with('*')) .collect(); let linetype_items: Vec = self.tabs[i] .scene @@ -2301,6 +2303,7 @@ impl OpenCADStudio { Some((set, constraint)) => { sections = dynamic_dimension_sections( &self.tabs[i].scene, + handle, set, constraint, sections, @@ -3614,22 +3617,30 @@ fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &st /// Replace a row's value with an arbitrary control (editable field, dropdown, /// colour picker …) rather than plain read-only text. -/// A dynamic dimension's Properties as the reference shows them: the -/// Constraint rows, then only the text rotation of the dimension itself. +/// A dimensional constraint's Properties as the reference shows them: the +/// Constraint rows, then only the text rotation for a dynamic dimension +/// and the full dimension sections for an annotational one. fn dynamic_dimension_sections( scene: &crate::scene::Scene, + handle: Handle, set: &crate::scene::parametric_constraints::ParametricConstraintSet, constraint: &crate::scene::parametric_constraints::ParametricConstraint, sections: Vec, ) -> Vec { use crate::scene::model::object::{PropSection, PropValue, Property}; use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; let table = if set.local_parameters.is_empty() { scene.named_parameters() } else { &set.local_parameters }; - let (name, expression, value) = match &constraint.driving_param { + let reference = !constraint.enabled; + let annotational = scene + .document + .get_entity(handle) + .is_some_and(|entity| entity.common().layer != DYNAMIC_DIMENSION_LAYER); + let (name, expression, value, description) = match &constraint.driving_param { Some(DrivingValue::Named(name)) => ( name.clone(), table @@ -3638,15 +3649,22 @@ fn dynamic_dimension_sections( .map(|parameter| parameter.source.clone()) .unwrap_or_default(), table.resolve(name).ok(), + table.description(name).to_string(), ), - Some(DrivingValue::Literal(value)) => (String::new(), format!("{value}"), Some(*value)), - None => (String::new(), String::new(), None), + Some(DrivingValue::Literal(value)) => { + (String::new(), format!("{value}"), Some(*value), String::new()) + } + None => (String::new(), String::new(), None, String::new()), }; let row = |label: &str, field: &'static str, value: PropValue| Property { label: label.to_string(), field, value, }; + let yes_no = |flag: bool| PropValue::Choice { + selected: if flag { t!("Yes") } else { t!("No") }.into_owned(), + options: vec![t!("Yes").into_owned(), t!("No").into_owned()], + }; let text_rotation = sections .iter() .flat_map(|section| section.props.iter()) @@ -3658,18 +3676,26 @@ fn dynamic_dimension_sections( row( t!("Constraint Form").as_ref(), "dyn_constraint_form", - PropValue::ReadOnly("Dynamic".to_string()), + PropValue::Choice { + selected: if annotational { "Annotational" } else { "Dynamic" }.to_string(), + options: vec!["Dynamic".to_string(), "Annotational".to_string()], + }, ), row( t!("Reference").as_ref(), "dyn_constraint_reference", - PropValue::ReadOnly(t!("No").into_owned()), + yes_no(reference), ), row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)), row( t!("Expression").as_ref(), "dyn_constraint_expression", - PropValue::EditText(expression), + // A reference constraint's expression is the measurement. + if reference { + PropValue::ReadOnly(expression) + } else { + PropValue::EditText(expression) + }, ), row( t!("Value").as_ref(), @@ -3679,11 +3705,13 @@ fn dynamic_dimension_sections( row( t!("Description").as_ref(), "dyn_constraint_description", - PropValue::ReadOnly(String::new()), + PropValue::EditText(description), ), ], }]; - if let Some(text_rotation) = text_rotation { + if annotational { + result.extend(sections); + } else if let Some(text_rotation) = text_rotation { result.push(PropSection { title: t!("Text").into_owned(), props: vec![text_rotation], diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5e2a18016..fc7ba2ebb 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2188,6 +2188,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { field: &'static str, value: String, ) -> Task { + if matches!(field, "dyn_constraint_form" | "dyn_constraint_reference") { + return self.on_dynamic_dimension_choice(field, &value); + } let i = self.active_tab; let handles = self.property_target_handles(i); @@ -3269,6 +3272,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if let Some(param_field) = dynamic_field { return self.on_dynamic_dimension_field_commit(field, param_field); } + if field == "dyn_constraint_description" { + return self.on_dynamic_dimension_description_commit(field); + } let i = self.active_tab; self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c5279e5a8..3b52c7c9b 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 a fixed screen size across zooms. + self.tabs[i].scene.refresh_dynamic_dimension_sizes(); // If the camera moved since we last synced, write it back to // the document and mark the file dirty. diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index a5c11d32e..1d34f8a97 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -204,6 +204,24 @@ impl DimConstraintCommand { ParametricRef::point(handle, end), ) } + // A text's baseline or an ellipse's axis is a line whose one + // constraint point is the insertion point / the center. + EntityType::Text(_) | EntityType::MText(_) => { + return Self::axis_target(entity, ParametricRef::text_baseline(handle), ParametricRef::point(handle, 0)); + } + EntityType::Ellipse(_) => { + let center = ParametricRef::point(handle, -3); + let major = Self::axis_target(entity, ParametricRef::ellipse_major_axis(handle), center); + let minor = Self::axis_target(entity, ParametricRef::ellipse_minor_axis(handle), center); + let gap = |target: &LineTarget| { + let offset = point - target.ends[0].1; + (offset.x * target.dir.y - offset.y * target.dir.x).abs() + }; + return match (major, minor) { + (Some(major), Some(minor)) => Some(if gap(&minor) < gap(&major) { minor } else { major }), + (major, minor) => major.or(minor), + }; + } _ => return None, }; let start = Self::world(entity, first)?; @@ -216,6 +234,24 @@ impl DimConstraintCommand { }) } + /// A directional axis (text baseline, ellipse axis) as a line target + /// whose only constraint point is `anchor`. + fn axis_target(entity: &EntityType, axis: ParametricRef, anchor: ParametricRef) -> Option { + let [start, end] = + crate::scene::parametric_constraints::directional_axis_endpoints(entity, axis)?; + let (start, end) = ( + DVec3::new(start.x, start.y, start.z), + DVec3::new(end.x, end.y, end.z), + ); + let dir = (end - start).try_normalize()?; + let anchor_point = Self::world(entity, anchor)?; + Some(LineTarget { + line: axis, + dir, + ends: [(anchor, anchor_point), (anchor, anchor_point)], + }) + } + /// A picked line for the current line step, or the message to show. fn pick_line(&mut self, handle: Handle, point: DVec3) -> Result { if handle.is_null() { diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dc8174bf5..14940a2c7 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1217,6 +1217,7 @@ impl Allocator<'_> { name: &str, formula: &str, resolved: f64, + description: &str, ) -> Handle { if let Some(&handle) = self.variables.get(name) { if let Some(ObjectType::Associative(object)) = self.document.objects.get_mut(&handle) { @@ -1244,7 +1245,7 @@ impl Allocator<'_> { name: name.to_string(), expression: formula.to_string(), evaluator: "AcDbCalc:1.0".to_string(), - description: String::new(), + description: description.to_string(), value: Self::numeric_eval(resolved), has_cached_value: false, cached_value: String::new(), @@ -1883,6 +1884,7 @@ fn driving_value( variable.expression.clone() }; if parameters.set(&variable.name, &source).is_ok() { + parameters.set_description(&variable.name, &variable.description); Some(DrivingValue::Named(variable.name.clone())) } else { literal @@ -2610,6 +2612,7 @@ fn materialize_scope( variable.action.action_index = index as i32 + 1; if let Some(parameter) = parameters.get(&variable.name) { variable.expression = parameter.source.clone(); + variable.description = parameters.description(¶meter.name).to_string(); if let Ok(resolved) = parameters.resolve(¶meter.name) { variable.value = Allocator::numeric_eval(resolved); } @@ -2621,7 +2624,13 @@ fn materialize_scope( if materialize_all_parameters { for parameter in parameters.iter() { let resolved = parameters.resolve(¶meter.name).unwrap_or(0.0); - allocator.variable(network_handle, ¶meter.name, ¶meter.source, resolved); + allocator.variable( + network_handle, + ¶meter.name, + ¶meter.source, + resolved, + parameters.description(¶meter.name), + ); } } @@ -2666,7 +2675,13 @@ 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 variable_handle = allocator.variable( + network_handle, + &name, + &formula, + resolved, + parameters.description(&name), + ); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = allocator.value_dependency(group_handle?, variable_handle, resolved, dependency_id); diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 0fdc9a53d..f0e7e8a22 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1404,7 +1404,7 @@ impl Scene { || (!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)) + || (plot_only && self.is_plot_hidden_dynamic_dimension(common.handle)) { return false; } diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs index da6327802..5ed9eb6d6 100644 --- a/src/scene/named_parameters.rs +++ b/src/scene/named_parameters.rs @@ -474,6 +474,9 @@ pub struct Parameter { #[derive(Debug, Clone, Default, PartialEq)] pub struct ParameterTable { parameters: Vec, + /// The free-text description a parameter carries (the reference's + /// Description column), by name. + descriptions: HashMap, } impl ParameterTable { @@ -481,6 +484,23 @@ impl ParameterTable { Self::default() } + pub fn description(&self, name: &str) -> &str { + self.descriptions.get(name).map_or("", String::as_str) + } + + /// Stores `description` for an existing parameter; an empty text clears it. + pub fn set_description(&mut self, name: &str, description: &str) { + if !self.contains(name) { + return; + } + if description.trim().is_empty() { + self.descriptions.remove(name); + } else { + self.descriptions + .insert(name.to_string(), description.trim().to_string()); + } + } + pub fn is_empty(&self) -> bool { self.parameters.is_empty() } @@ -549,6 +569,7 @@ impl ParameterTable { /// need to invent (a future UI stage may still want to warn before /// deleting — that's a UI-layer decision, not a data-layer one). pub fn remove(&mut self, name: &str) -> bool { + self.descriptions.remove(name); match self.index_of(name) { Some(i) => { self.parameters.remove(i); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 1dd7f4b3d..edc8aa387 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -364,16 +364,59 @@ 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 layer the reference keeps dynamic dimensions on; an annotational +/// dimensional constraint sits on an ordinary layer instead. +pub(crate) const DYNAMIC_DIMENSION_LAYER: &str = "*ADSK_CONSTRAINTS"; + /// 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 { +/// 1 = value, 2 = name=value, the value with its trailing zeros dropped +/// (`d9=80`, `d1=111.8034`). A parameter defined by a formula shows the +/// formula behind `fx:` (`fx: len=d1*2`); a reference (driven) constraint's +/// text sits in parentheses. +pub(crate) fn dynamic_dimension_text( + name: &str, + value: f64, + format: u8, + reference: bool, + expression: Option<&str>, +) -> String { + let formula = expression + .map(str::trim) + .filter(|source| !source.is_empty() && source.parse::().is_err()); + let shown = match formula { + Some(source) => source.to_string(), + None => { + 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() + } + } + }; + let text = match format { 0 => name.to_string(), - 1 => format!("{value:.4}"), - _ => format!("{name}={value:.4}"), + 1 => shown, + _ => format!("{name}={shown}"), + }; + let text = if formula.is_some() { + format!("fx: {text}") + } else { + text + }; + if reference { + format!("({text})") + } else { + text } } +/// Screen height of a dynamic dimension's text and its arrows, in pixels — +/// the reference keeps them readable at any zoom. +const DYNAMIC_TEXT_PX: f32 = 12.0; +const DYNAMIC_ARROW_PX: f32 = 8.0; + /// The expression a measured value seeds a parameter with: twelve /// significant digits with the trailing zeros dropped (`100`, /// `111.803398875`), as the reference stores it. @@ -1593,8 +1636,11 @@ impl super::Scene { pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { let format = self.constraint_name_format; let mut updates: Vec<(Handle, String, Option<(Vector3, Vector3)>)> = Vec::new(); + // A reference (driven) constraint's parameter follows the geometry. + let mut followed: Vec<(String, f64)> = Vec::new(); for set in &self.parametric_constraints { - let table = if set.local_parameters.is_empty() { + let model_table = set.local_parameters.is_empty(); + let table = if model_table { &self.named_parameters } else { &set.local_parameters @@ -1603,13 +1649,6 @@ 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)) - } - Some(DrivingValue::Literal(value)) => (String::new(), *value), - None => continue, - }; let world = |reference: &ParametricRef| { let entity = self.document.get_entity(reference.entity)?; resolve_point(entity, reference.marker?) @@ -1618,13 +1657,56 @@ impl super::Scene { [first, second, ..] => world(first).zip(world(second)), _ => None, }; + let reference = !constraint.enabled; + let measured = points.map(|(first, second)| { + let delta = second - first; + let axis = match self.document.get_entity(*dimension) { + Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(linear), + )) => Some((linear.rotation.cos(), linear.rotation.sin())), + _ => None, + }; + match (constraint.kind, axis) { + (ConstraintKind::DistanceX, _) => delta.x.abs(), + (ConstraintKind::DistanceY, _) => delta.y.abs(), + (ConstraintKind::DistanceDirected, Some((ax, ay))) => { + (delta.x * ax + delta.y * ay).abs() + } + _ => delta.length(), + } + }); + let (name, value, source) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => { + let value = match (reference, measured) { + (true, Some(measured)) => { + if model_table { + followed.push((name.clone(), measured)); + } + measured + } + _ => table.resolve(name).unwrap_or(f64::NAN), + }; + let source = (!reference) + .then(|| table.get(name).map(|parameter| parameter.source.clone())) + .flatten(); + (name.clone(), value, source) + } + Some(DrivingValue::Literal(value)) => (String::new(), *value, None), + None => continue, + }; updates.push(( *dimension, - dynamic_dimension_text(&name, value, format), + dynamic_dimension_text(&name, value, format, reference, source.as_deref()), points, )); } } + for (name, measured) in followed { + let source = measured_expression(measured); + if self.named_parameters.get(&name).map(|p| p.source.as_str()) != Some(source.as_str()) { + let _ = self.named_parameters.set(&name, &source); + } + } for (handle, text, points) in updates { let Some(acadrust::EntityType::Dimension(mut dimension)) = self.document.get_entity(handle).cloned() @@ -1717,6 +1799,63 @@ impl super::Scene { ids.len() } + /// True for a dynamic-form dimensional constraint dimension — the ones + /// on the reference's constraints layer, which never plot; an + /// annotational one on an ordinary layer plots like any dimension. + pub(crate) fn is_plot_hidden_dynamic_dimension(&self, handle: Handle) -> bool { + self.is_dynamic_dimension(handle) + && self + .document + .get_entity(handle) + .is_some_and(|entity| entity.common().layer == DYNAMIC_DIMENSION_LAYER) + } + + /// Keeps dynamic dimensions readable at any zoom: their text and arrows + /// measure a fixed screen size, as the reference draws them. Runs every + /// tick and touches an entity only when the zoom moved it off size. + pub(crate) fn refresh_dynamic_dimension_sizes(&mut self) { + use crate::entities::dim_override as dov; + let Some(wpp) = self.world_per_pixel() else { + return; + }; + let text_height = (DYNAMIC_TEXT_PX * wpp) as f64; + let arrow = (DYNAMIC_ARROW_PX * wpp) as f64; + let handles: Vec = self + .parametric_constraints + .iter() + .flat_map(|set| set.dimensions.values().copied()) + .collect(); + for handle in handles { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + // An annotational dimension keeps its style's sizes. + if entity.common().layer != DYNAMIC_DIMENSION_LAYER { + continue; + } + let current = dov::real(&entity.common().extended_data, dov::DIMTXT); + if current.is_some_and(|height| (height - text_height).abs() <= text_height * 0.02) { + continue; + } + let mut entity = entity.clone(); + dov::set_on_entity(&mut entity, dov::DIMSCALE, Some(acadrust::xdata::XDataValue::Real(1.0))); + dov::set_on_entity(&mut entity, dov::DIMTXT, Some(acadrust::xdata::XDataValue::Real(text_height))); + dov::set_on_entity(&mut entity, dov::DIMASZ, Some(acadrust::xdata::XDataValue::Real(arrow))); + self.update_entity(entity); + } + } + + /// Makes sure the reference's dynamic dimension layer exists. + pub(crate) fn ensure_dynamic_dimension_layer(&mut self) { + if self.document.layers.contains(DYNAMIC_DIMENSION_LAYER) { + return; + } + let mut layer = acadrust::tables::Layer::new(DYNAMIC_DIMENSION_LAYER); + layer.color = acadrust::types::Color::from_index(7); + layer.handle = self.document.allocate_handle(); + let _ = self.document.layers.add(layer); + } + /// True for a dimension that shows a dimensional constraint. pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { self.parametric_constraints diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 61753cb4a..ba7660548 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1625,10 +1625,13 @@ fn build_constraint( distance_direction_type::PARALLEL_TO_LINE | distance_direction_type::PERPENDICULAR_TO_LINE ) { - if let Some(line) = direction_ref - .first() - .and_then(|reference| whole_line(sys, cache, *reference)) - { + if let Some(line) = direction_ref.first().and_then(|reference| { + // A text baseline or an ellipse axis directs the + // distance the same way a line does. + whole_line(sys, cache, *reference).or_else(|| { + directional_line(sys, cache, *reference).map(|(line, _)| line) + }) + }) { let current = { let store = sys.store(); let delta = [ @@ -3808,6 +3811,24 @@ impl Scene { self.parametric_constraints[i].dof = None; self.parametric_constraints[i].conflicts.clear(); } + // A dimensional constraint set to zero means the collapse. + let zero_collapse: HashSet = { + let set = &self.parametric_constraints[i]; + let params = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; + set.constraints + .iter() + .filter(|c| { + c.enabled + && c.kind == ConstraintKind::Distance + && resolved_target(params, c).is_some_and(|v| v.abs() <= f64::EPSILON) + }) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect() + }; for (handle, new_entity) in solved { // An edit the constraints can only satisfy by collapsing the // entity (a rotated line whose start is fixed and direction @@ -3816,7 +3837,8 @@ impl Scene { let new_entity = match originals.get(&handle) { Some(original) if collapsed_by_solve(&new_entity) - && !collapsed_by_solve(original.as_ref()) => + && !collapsed_by_solve(original.as_ref()) + && !zero_collapse.contains(&handle) => { original.as_ref().clone() } diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index bf2ee1d9f..b1aba4906 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -206,6 +206,8 @@ impl LayerPanel { self.layers = doc_layers .iter() + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|l| !l.name.starts_with('*')) .map(|l| { let layer_handle = l.handle; let vp_frozen = vp_info From 8e83b2f8432742403fbe7e684fd1bae899441edd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:38:26 +0300 Subject: [PATCH 3/3] Aligned constraint: 2Lines turns the second line with the plain Parallel solve Only the first line's ends are pinned while the second line is made parallel, the way the Parallel constraint itself moves it; the distance is then measured on the solved geometry. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b444ffd3b..1af8134cf 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4000,13 +4000,11 @@ impl OpenCADStudio { id, self.constraint_bar_display, ); - // The first line stays; the second turns about its start - // to run parallel, as the reference keeps that start. - let mut pins = first_ends.to_vec(); - pins.push(second_ends[0]); + // The first line stays; the second turns parallel the way the + // Parallel constraint itself moves it. self.tabs[i].scene.bump_entities_with_parametric_policy( &[(second_line.entity, crate::scene::ChangeKind::Modified)], - &pins, + &first_ends, self.constraint_solve_mode, ); self.tabs[i].dirty = true;