diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 75c919ede..09776bd3b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1553,7 +1553,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() @@ -1562,8 +1563,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(); @@ -1573,25 +1590,9 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - // A changed value moves a dimensional constraint's second point; its - // first point stays, as in the reference. - let anchors: Vec = self.tabs[i] + self.tabs[i] .scene - .parametric_constraints - .iter() - .flat_map(|set| set.constraints.iter()) - .filter(|c| { - c.enabled - && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) - }) - .filter_map(|c| c.refs.first().copied()) - .filter(|reference| reference.marker.is_some()) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &anchors, - self.constraint_solve_mode, - ); + .bump_entities_with_parametric_policy(&changes, &anchors, false); } /// Commits one field of one Parameters-section row (Properties panel) — @@ -1660,6 +1661,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() @@ -1668,12 +1674,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()) } }; @@ -1728,6 +1741,190 @@ 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| { + use crate::entities::dim_override as dov; + 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; + // The dynamic form's screen-size and horizontal-text + // overrides go; the style draws it. + for code in [dov::DIMSCALE, dov::DIMTIH, dov::DIMTOH] { + dov::set_on_entity(&mut entity, code, None); + } + } 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); + }); + // The dynamic form is rescaled to the screen and shows the + // trimmed value; the annotational one is never hidden. + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + } + "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( + &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 @@ -3827,26 +4024,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."); @@ -3860,23 +4093,24 @@ 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(); } }; - // DCFORM Annotational: an ordinary plotted dimension that shows - // the value at dimension precision. + // DCFORM Annotational: an ordinary plotted dimension on the + // current layer that shows the value at dimension precision. let annotational = self.constraint_form_annotational; - let source = if annotational { - format!("{value:.4}") - } else { - expression.clone() - }; let text = dynamic_dimension_text( &name, - &source, value, self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), + annotational, ); let mut entity = if kind == ConstraintKind::Distance { aligned_dim::aligned_dimension_entity( @@ -3898,13 +4132,18 @@ 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 draws in the constraint gray, not the - // current color. - if !annotational { + if annotational { + entity + .as_entity_mut() + .set_layer(self.tabs[i].active_layer.clone()); + } else { + // A dynamic dimension lives on the reference's constraints + // layer, hidden from the layer lists and never plotted, and + // draws in the constraint gray, not the current color. + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); entity.common_mut().color = acadrust::types::Color::Rgb { r: 103, g: 109, @@ -3919,8 +4158,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] @@ -3929,11 +4170,14 @@ impl OpenCADStudio { self.tabs[i].scene.record_undo_named_parameters_before(); self.tabs[i].scene.named_parameters = table; let dimension = self.tabs[i].scene.add_entity(entity); - if annotational { - self.tabs[i].scene.mark_dimension_annotational(dimension); - } let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + 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, @@ -3948,13 +4192,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].scene.refresh_dynamic_dimension_scales(true); @@ -3966,6 +4210,90 @@ 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 the way the + // Parallel constraint itself moves 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::{ @@ -8373,10 +8701,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 b96b37e04..d1982697a 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 @@ -2292,6 +2294,29 @@ 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, + handle, + 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 +2764,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 +3617,105 @@ 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 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; + let table = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; + let reference = !constraint.enabled; + let annotational = scene.dimension_is_annotational(handle); + let (name, expression, value, description) = 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(), + table.description(name).to_string(), + ), + 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()) + .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::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", + yes_no(reference), + ), + row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)), + row( + t!("Expression").as_ref(), + "dyn_constraint_expression", + // A reference constraint's expression is the measurement. + if reference { + PropValue::ReadOnly(expression) + } else { + 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::EditText(description), + ), + ], + }]; + 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], + }); + } + 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..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); @@ -3259,6 +3262,19 @@ 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); + } + 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/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 6f7efb264..310c4983a 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,8 +6295,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } } -/// Where a dynamic dimension's lock mark sits: just after the (horizontal) -/// text, with the direction that points away from the text. +/// 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, dim: &Dimension, @@ -6317,13 +6317,14 @@ pub(crate) fn dynamic_dimension_lock_anchor( let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); let half_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH * 0.5; let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); - let outward = Vector3::new(1.0, 0.0, 0.0); + // The text angle the renderer draws (DIMTIH/DIMTOH overrides make a + // dynamic dimension's text horizontal). + let (sr, cr) = dimension_text_rotation(dim, style).sin_cos(); + 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 d3e5dadf4..746cff97f 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,108 @@ 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), + ) + } + // 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)?; + let finish = Self::world(entity, second)?; + let dir = (finish - start).try_normalize()?; + Some(LineTarget { + line, + dir, + ends: [(first, start), (second, finish)], + }) + } + + /// 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() { + 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 +298,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 +316,7 @@ impl DimConstraintCommand { location, axis, kind, + direction, .. } = self.step else { @@ -182,6 +333,7 @@ impl DimConstraintCommand { second_point, location, axis, + direction: direction.map(|line| line.line), name, expression, label: match self.axis { @@ -202,11 +354,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 +381,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 +398,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 +440,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 +478,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 +567,7 @@ impl CadCommand for DimConstraintCommand { second, first_point, second_point, + direction: None, }; CmdResult::NeedPoint } @@ -315,6 +575,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 +619,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 +650,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 +680,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 +694,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/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index bf5901e94..cde66ee7e 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/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 cd01ad47a..6cf0caa26 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -367,41 +367,197 @@ pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; /// The on-screen text height of a dynamic dimension, in pixels. pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; -/// The xdata application that marks an annotational constraint dimension. -pub const ANNOTATIONAL_DIMENSION_APP: &str = "OCS_CONSTRAINT_FORM"; +/// The 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. A plain number shows as typed (`d1=100`); an -/// expression shows the expression under the reference's `fx:` prefix -/// (`fx: d2=d1*2`). -pub(crate) fn dynamic_dimension_text(name: &str, source: &str, value: f64, format: u8) -> String { - let source = source.trim(); - let literal = source.parse::().is_ok(); - let shown = if literal { - source.to_string() - } else { - measured_expression(value) +/// 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; an annotational one keeps dimension precision. +pub(crate) fn dynamic_dimension_text( + name: &str, + value: f64, + format: u8, + reference: bool, + expression: Option<&str>, + annotational: bool, +) -> 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(), + // An annotational dimension shows the value at dimension precision + // (`d1=100.0000`), as the reference does. + None if annotational => format!("{value:.4}"), + 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() + } + } }; - match format { + let text = match format { 0 => name.to_string(), - 1 if literal => shown, - 1 => format!("fx: {shown}"), - _ if literal => format!("{name}={shown}"), - _ => format!("fx: {name}={source}"), + 1 => shown, + _ => format!("{name}={shown}"), + }; + let text = if formula.is_some() { + format!("fx: {text}") + } else { + text + }; + if reference { + format!("({text})") + } else { + text } } -/// 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( @@ -1477,12 +1633,16 @@ 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(); + // 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 @@ -1491,47 +1651,94 @@ impl super::Scene { let Some(constraint) = set.get(*id) else { continue; }; - let (name, source, value) = match &constraint.driving_param { - Some(DrivingValue::Named(name)) => ( - name.clone(), - table - .get(name) - .map(|parameter| parameter.source.clone()) - .unwrap_or_default(), - table.resolve(name).unwrap_or(f64::NAN), - ), - Some(DrivingValue::Literal(value)) => { - (String::new(), measured_expression(*value), *value) + 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, + }; + 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, }; - // An annotational dimension shows the value at dimension - // precision, as the reference does. - let source = if self.dimension_is_annotational(*dimension) { - format!("{value:.4}") - } else { - source - }; + let annotational = self.dimension_is_annotational(*dimension); updates.push(( *dimension, - dynamic_dimension_text(&name, &source, value, format), + dynamic_dimension_text( + &name, + value, + format, + reference, + source.as_deref(), + annotational, + ), + points, )); } } - for (handle, text) in updates { + 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() 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)); } } @@ -1602,6 +1809,19 @@ impl super::Scene { ids.len() } + /// Makes sure the reference's dynamic dimension layer exists: hidden + /// from the layer lists (the `*` prefix) and never plotted. + 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.is_plottable = false; + layer.handle = self.document.allocate_handle(); + let _ = self.document.layers.add(layer); + } + /// True for a dynamic dimension of a dimensional constraint (not an /// annotational one, which is an ordinary plotted dimension). pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { @@ -1612,29 +1832,17 @@ impl super::Scene { } /// True when a dimensional constraint's dimension uses the annotational - /// form: drawn with its style, plotted, never rescaled to the screen. + /// form: on an ordinary layer instead of the reference's constraints + /// layer, drawn with its style, plotted, never rescaled to the screen. pub(crate) fn dimension_is_annotational(&self, handle: Handle) -> bool { self.document.get_entity(handle).is_some_and(|entity| { - entity + !entity .common() - .extended_data - .get_record(ANNOTATIONAL_DIMENSION_APP) - .is_some() + .layer + .eq_ignore_ascii_case(DYNAMIC_DIMENSION_LAYER) }) } - /// Marks a constraint dimension as annotational (DCFORM Annotational). - pub(crate) fn mark_dimension_annotational(&mut self, handle: Handle) { - crate::scene::view::dispatch::set_entity_xdata( - &mut self.document, - handle, - ANNOTATIONAL_DIMENSION_APP, - Some(vec![acadrust::xdata::XDataValue::String( - "ANNOTATIONAL".to_string(), - )]), - ); - } - /// The document as written to a file: a dynamic dimension's screen-size /// overrides are a display matter and stay out of the file. pub(crate) fn document_for_save(&self) -> acadrust::CadDocument { diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 2ce85d700..00ae2ae85 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) } @@ -1592,6 +1598,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))] } @@ -1613,10 +1627,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 = [ @@ -3812,6 +3829,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 @@ -3820,7 +3855,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