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))] }