From 5fab97a7d0067c55ef4e7e7e9f833a9c4f6c7cd0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:04:26 +0300 Subject: [PATCH] Aligned constraint: axis lines, zero collapse, Properties form/reference/description, screen-sized text Point & line and 2Lines take a text baseline or an ellipse axis as the line; a zero value folds the points onto the first one; 2Lines turns the second line about its start. The Properties Constraint section edits Constraint Form (Dynamic on the *ADSK_CONSTRAINTS layer, Annotational on the current layer), Reference (driven constraint in parentheses whose parameter follows the measurement) and Description (stored with the parameter and the native variable). Dynamic dimensions keep a 12 px text and 8 px arrows at any zoom, drop trailing zeros and show formulas as `fx: len=d1*2`; the hidden constraints layer stays out of the layer lists. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 182 +++++++++++++++++++++-- src/app/layers.rs | 2 + src/app/properties.rs | 48 ++++-- src/app/update/command.rs | 6 + src/app/update/viewport.rs | 2 + src/modules/parametric/dim_constraint.rs | 36 +++++ src/scene/dwg_native_constraints.rs | 21 ++- src/scene/entity.rs | 2 +- src/scene/named_parameters.rs | 21 +++ src/scene/parametric_constraints.rs | 167 +++++++++++++++++++-- src/scene/parametric_solve.rs | 32 +++- src/ui/window/layers.rs | 2 + 12 files changed, 476 insertions(+), 45 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 29a1bbd02..b444ffd3b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1432,6 +1432,11 @@ impl OpenCADStudio { current.name.clone(), ), ParamField::Name => { + let description = self.tabs[i] + .scene + .named_parameters() + .description(¤t.name) + .to_string(); self.tabs[i] .scene .named_parameters_mut() @@ -1440,12 +1445,19 @@ impl OpenCADStudio { .scene .named_parameters_mut() .set(&typed, ¤t.source); - if outcome.is_err() { + let kept = if outcome.is_err() { let _ = self.tabs[i] .scene .named_parameters_mut() .set(¤t.name, ¤t.source); - } + current.name.as_str() + } else { + typed.as_str() + }; + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(kept, &description); (outcome, typed.clone()) } }; @@ -1500,6 +1512,133 @@ impl OpenCADStudio { Task::none() } + /// The dynamic dimension the Properties panel targets: its handle, scope, + /// constraint id and parameter name. + fn dynamic_dimension_target( + &self, + i: usize, + ) -> Option<( + Handle, + crate::scene::parametric_constraints::ParametricScope, + crate::scene::parametric_constraints::ConstraintId, + String, + )> { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + let handle = *self.property_target_handles(i).first()?; + let (set, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, handle)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + Some((handle, set.scope, constraint.id, name.clone())) + } + + /// Commits a dynamic dimension's Description row into its parameter. + pub(super) fn on_dynamic_dimension_description_commit( + &mut self, + field: &'static str, + ) -> Task { + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let Some((_, _, _, name)) = self.dynamic_dimension_target(i) else { + self.refresh_properties(); + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint description", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(&name, &typed); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// Applies a dynamic dimension's Constraint Form or Reference choice. + pub(super) fn on_dynamic_dimension_choice( + &mut self, + field: &'static str, + value: &str, + ) -> Task { + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; + let i = self.active_tab; + let Some((handle, scope, id, _)) = self.dynamic_dimension_target(i) else { + return Task::none(); + }; + match field { + "dyn_constraint_form" => { + // Annotational: an ordinary dimension on the current layer + // that plots; Dynamic: the gray one on the constraints layer. + let annotational = value.eq_ignore_ascii_case("Annotational"); + let active_layer = self.tabs[i].active_layer.clone(); + if !annotational { + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + } + self.apply_property_op(i, "Constraint form", &[handle], |app, handle| { + let Some(mut entity) = app.tabs[i].scene.document.get_entity(handle).cloned() + else { + return; + }; + if annotational { + entity.as_entity_mut().set_layer(active_layer.clone()); + entity.common_mut().color = acadrust::types::Color::ByLayer; + } else { + entity + .as_entity_mut() + .set_layer(DYNAMIC_DIMENSION_LAYER.to_string()); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + app.tabs[i].scene.update_entity(entity); + }); + } + "dyn_constraint_reference" => { + // A reference constraint reads the geometry instead of + // driving it; its text sits in parentheses. + let reference = + value.eq_ignore_ascii_case("Yes") || value == crate::t!("Yes").as_ref(); + let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() + else { + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint reference", 0, true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + self.tabs[i].scene.record_undo_named_parameters_before(); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.enabled = !reference; + } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + } + _ => {} + } + Task::none() + } + /// Commits a dynamic dimension's Name or Expression row: the typed text /// goes to the Parameters-section row of the constraint's parameter. pub(super) fn on_dynamic_dimension_field_commit( @@ -3718,6 +3857,8 @@ impl OpenCADStudio { &name, value, self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), ); let mut entity = if kind == ConstraintKind::Distance { aligned_dim::aligned_dimension_entity( @@ -3739,10 +3880,12 @@ impl OpenCADStudio { &self.tabs[i].scene.document, &mut entity, ); - let layer = self.tabs[i].active_layer.clone(); - if layer != "0" { - entity.as_entity_mut().set_layer(layer); - } + // A dynamic dimension lives on the reference's constraints + // layer, hidden from the layer lists and never plotted. + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); // A dynamic dimension draws in the constraint gray, not the // current color. entity.common_mut().color = acadrust::types::Color::Rgb { @@ -3857,10 +4000,13 @@ impl OpenCADStudio { id, self.constraint_bar_display, ); - // The first line stays; the second turns parallel to it. + // The first line stays; the second turns about its start + // to run parallel, as the reference keeps that start. + let mut pins = first_ends.to_vec(); + pins.push(second_ends[0]); self.tabs[i].scene.bump_entities_with_parametric_policy( &[(second_line.entity, crate::scene::ChangeKind::Modified)], - &first_ends, + &pins, self.constraint_solve_mode, ); self.tabs[i].dirty = true; @@ -8299,10 +8445,22 @@ fn entity_at_typed_point( extent = extent .max((bounds.max.x - bounds.min.x).abs()) .max((bounds.max.y - bounds.min.y).abs()); - let Some(distance) = - crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) - else { - continue; + let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { + Some(distance) => distance, + // A text or an ellipse answers by its extent, the way a click + // inside it picks it. + None if matches!( + entity, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Ellipse(_) + ) => + { + let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0); + let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0); + dx.hypot(dy) + } + None => continue, }; if nearest.is_none_or(|(best, _)| distance < best) { nearest = Some((distance, common.handle)); diff --git a/src/app/layers.rs b/src/app/layers.rs index 6be69c8f3..24792f24c 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -62,6 +62,8 @@ impl OpenCADStudio { .layers .layers .iter() + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|l| !l.name.starts_with('*')) .map(|l| crate::ui::ribbon::LayerInfo { name: l.name.clone(), color: crate::ui::window::layers::iced_color_from_acad(&l.color), diff --git a/src/app/properties.rs b/src/app/properties.rs index 185a0ff2a..edc43673f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -79,6 +79,8 @@ impl OpenCADStudio { .layers .iter() .map(|l| l.name.clone()) + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|name| !name.starts_with('*')) .collect(); let linetype_items: Vec = self.tabs[i] .scene @@ -2301,6 +2303,7 @@ impl OpenCADStudio { Some((set, constraint)) => { sections = dynamic_dimension_sections( &self.tabs[i].scene, + handle, set, constraint, sections, @@ -3614,22 +3617,30 @@ fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &st /// Replace a row's value with an arbitrary control (editable field, dropdown, /// colour picker …) rather than plain read-only text. -/// A dynamic dimension's Properties as the reference shows them: the -/// Constraint rows, then only the text rotation of the dimension itself. +/// A dimensional constraint's Properties as the reference shows them: the +/// Constraint rows, then only the text rotation for a dynamic dimension +/// and the full dimension sections for an annotational one. fn dynamic_dimension_sections( scene: &crate::scene::Scene, + handle: Handle, set: &crate::scene::parametric_constraints::ParametricConstraintSet, constraint: &crate::scene::parametric_constraints::ParametricConstraint, sections: Vec, ) -> Vec { use crate::scene::model::object::{PropSection, PropValue, Property}; use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; let table = if set.local_parameters.is_empty() { scene.named_parameters() } else { &set.local_parameters }; - let (name, expression, value) = match &constraint.driving_param { + let reference = !constraint.enabled; + let annotational = scene + .document + .get_entity(handle) + .is_some_and(|entity| entity.common().layer != DYNAMIC_DIMENSION_LAYER); + let (name, expression, value, description) = match &constraint.driving_param { Some(DrivingValue::Named(name)) => ( name.clone(), table @@ -3638,15 +3649,22 @@ fn dynamic_dimension_sections( .map(|parameter| parameter.source.clone()) .unwrap_or_default(), table.resolve(name).ok(), + table.description(name).to_string(), ), - Some(DrivingValue::Literal(value)) => (String::new(), format!("{value}"), Some(*value)), - None => (String::new(), String::new(), None), + Some(DrivingValue::Literal(value)) => { + (String::new(), format!("{value}"), Some(*value), String::new()) + } + None => (String::new(), String::new(), None, String::new()), }; let row = |label: &str, field: &'static str, value: PropValue| Property { label: label.to_string(), field, value, }; + let yes_no = |flag: bool| PropValue::Choice { + selected: if flag { t!("Yes") } else { t!("No") }.into_owned(), + options: vec![t!("Yes").into_owned(), t!("No").into_owned()], + }; let text_rotation = sections .iter() .flat_map(|section| section.props.iter()) @@ -3658,18 +3676,26 @@ fn dynamic_dimension_sections( row( t!("Constraint Form").as_ref(), "dyn_constraint_form", - PropValue::ReadOnly("Dynamic".to_string()), + PropValue::Choice { + selected: if annotational { "Annotational" } else { "Dynamic" }.to_string(), + options: vec!["Dynamic".to_string(), "Annotational".to_string()], + }, ), row( t!("Reference").as_ref(), "dyn_constraint_reference", - PropValue::ReadOnly(t!("No").into_owned()), + yes_no(reference), ), row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)), row( t!("Expression").as_ref(), "dyn_constraint_expression", - PropValue::EditText(expression), + // A reference constraint's expression is the measurement. + if reference { + PropValue::ReadOnly(expression) + } else { + PropValue::EditText(expression) + }, ), row( t!("Value").as_ref(), @@ -3679,11 +3705,13 @@ fn dynamic_dimension_sections( row( t!("Description").as_ref(), "dyn_constraint_description", - PropValue::ReadOnly(String::new()), + PropValue::EditText(description), ), ], }]; - if let Some(text_rotation) = text_rotation { + if annotational { + result.extend(sections); + } else if let Some(text_rotation) = text_rotation { result.push(PropSection { title: t!("Text").into_owned(), props: vec![text_rotation], diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5e2a18016..fc7ba2ebb 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2188,6 +2188,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { field: &'static str, value: String, ) -> Task { + if matches!(field, "dyn_constraint_form" | "dyn_constraint_reference") { + return self.on_dynamic_dimension_choice(field, &value); + } let i = self.active_tab; let handles = self.property_target_handles(i); @@ -3269,6 +3272,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if let Some(param_field) = dynamic_field { return self.on_dynamic_dimension_field_commit(field, param_field); } + if field == "dyn_constraint_description" { + return self.on_dynamic_dimension_description_commit(field); + } let i = self.active_tab; self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c5279e5a8..3b52c7c9b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -785,6 +785,8 @@ impl OpenCADStudio { pub(super) fn on_tick(&mut self, t: Instant) -> Task { let i = self.active_tab; self.tabs[i].scene.update(t - self.start); + // Dynamic dimensions keep a fixed screen size across zooms. + self.tabs[i].scene.refresh_dynamic_dimension_sizes(); // If the camera moved since we last synced, write it back to // the document and mark the file dirty. diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index a5c11d32e..1d34f8a97 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -204,6 +204,24 @@ impl DimConstraintCommand { ParametricRef::point(handle, end), ) } + // A text's baseline or an ellipse's axis is a line whose one + // constraint point is the insertion point / the center. + EntityType::Text(_) | EntityType::MText(_) => { + return Self::axis_target(entity, ParametricRef::text_baseline(handle), ParametricRef::point(handle, 0)); + } + EntityType::Ellipse(_) => { + let center = ParametricRef::point(handle, -3); + let major = Self::axis_target(entity, ParametricRef::ellipse_major_axis(handle), center); + let minor = Self::axis_target(entity, ParametricRef::ellipse_minor_axis(handle), center); + let gap = |target: &LineTarget| { + let offset = point - target.ends[0].1; + (offset.x * target.dir.y - offset.y * target.dir.x).abs() + }; + return match (major, minor) { + (Some(major), Some(minor)) => Some(if gap(&minor) < gap(&major) { minor } else { major }), + (major, minor) => major.or(minor), + }; + } _ => return None, }; let start = Self::world(entity, first)?; @@ -216,6 +234,24 @@ impl DimConstraintCommand { }) } + /// A directional axis (text baseline, ellipse axis) as a line target + /// whose only constraint point is `anchor`. + fn axis_target(entity: &EntityType, axis: ParametricRef, anchor: ParametricRef) -> Option { + let [start, end] = + crate::scene::parametric_constraints::directional_axis_endpoints(entity, axis)?; + let (start, end) = ( + DVec3::new(start.x, start.y, start.z), + DVec3::new(end.x, end.y, end.z), + ); + let dir = (end - start).try_normalize()?; + let anchor_point = Self::world(entity, anchor)?; + Some(LineTarget { + line: axis, + dir, + ends: [(anchor, anchor_point), (anchor, anchor_point)], + }) + } + /// A picked line for the current line step, or the message to show. fn pick_line(&mut self, handle: Handle, point: DVec3) -> Result { if handle.is_null() { diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dc8174bf5..14940a2c7 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1217,6 +1217,7 @@ impl Allocator<'_> { name: &str, formula: &str, resolved: f64, + description: &str, ) -> Handle { if let Some(&handle) = self.variables.get(name) { if let Some(ObjectType::Associative(object)) = self.document.objects.get_mut(&handle) { @@ -1244,7 +1245,7 @@ impl Allocator<'_> { name: name.to_string(), expression: formula.to_string(), evaluator: "AcDbCalc:1.0".to_string(), - description: String::new(), + description: description.to_string(), value: Self::numeric_eval(resolved), has_cached_value: false, cached_value: String::new(), @@ -1883,6 +1884,7 @@ fn driving_value( variable.expression.clone() }; if parameters.set(&variable.name, &source).is_ok() { + parameters.set_description(&variable.name, &variable.description); Some(DrivingValue::Named(variable.name.clone())) } else { literal @@ -2610,6 +2612,7 @@ fn materialize_scope( variable.action.action_index = index as i32 + 1; if let Some(parameter) = parameters.get(&variable.name) { variable.expression = parameter.source.clone(); + variable.description = parameters.description(¶meter.name).to_string(); if let Ok(resolved) = parameters.resolve(¶meter.name) { variable.value = Allocator::numeric_eval(resolved); } @@ -2621,7 +2624,13 @@ fn materialize_scope( if materialize_all_parameters { for parameter in parameters.iter() { let resolved = parameters.resolve(¶meter.name).unwrap_or(0.0); - allocator.variable(network_handle, ¶meter.name, ¶meter.source, resolved); + allocator.variable( + network_handle, + ¶meter.name, + ¶meter.source, + resolved, + parameters.description(¶meter.name), + ); } } @@ -2666,7 +2675,13 @@ fn materialize_scope( DrivingValue::Literal(_) => (format!("d{node_id}"), resolved.to_string()), }; node_names.insert(node_id, name.clone()); - let variable_handle = allocator.variable(network_handle, &name, &formula, resolved); + let variable_handle = allocator.variable( + network_handle, + &name, + &formula, + resolved, + parameters.description(&name), + ); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = allocator.value_dependency(group_handle?, variable_handle, resolved, dependency_id); diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 0fdc9a53d..f0e7e8a22 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1404,7 +1404,7 @@ impl Scene { || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) || self.hidden_dynamic_dimensions.contains(&common.handle) // A dynamic dimension never plots. - || (plot_only && self.is_dynamic_dimension(common.handle)) + || (plot_only && self.is_plot_hidden_dynamic_dimension(common.handle)) { return false; } diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs index da6327802..5ed9eb6d6 100644 --- a/src/scene/named_parameters.rs +++ b/src/scene/named_parameters.rs @@ -474,6 +474,9 @@ pub struct Parameter { #[derive(Debug, Clone, Default, PartialEq)] pub struct ParameterTable { parameters: Vec, + /// The free-text description a parameter carries (the reference's + /// Description column), by name. + descriptions: HashMap, } impl ParameterTable { @@ -481,6 +484,23 @@ impl ParameterTable { Self::default() } + pub fn description(&self, name: &str) -> &str { + self.descriptions.get(name).map_or("", String::as_str) + } + + /// Stores `description` for an existing parameter; an empty text clears it. + pub fn set_description(&mut self, name: &str, description: &str) { + if !self.contains(name) { + return; + } + if description.trim().is_empty() { + self.descriptions.remove(name); + } else { + self.descriptions + .insert(name.to_string(), description.trim().to_string()); + } + } + pub fn is_empty(&self) -> bool { self.parameters.is_empty() } @@ -549,6 +569,7 @@ impl ParameterTable { /// need to invent (a future UI stage may still want to warn before /// deleting — that's a UI-layer decision, not a data-layer one). pub fn remove(&mut self, name: &str) -> bool { + self.descriptions.remove(name); match self.index_of(name) { Some(i) => { self.parameters.remove(i); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 1dd7f4b3d..edc8aa387 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -364,16 +364,59 @@ pub(crate) fn next_dimensional_parameter_name( /// The constraint-bar label that draws a dynamic dimension's lock mark. pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; +/// The layer the reference keeps dynamic dimensions on; an annotational +/// dimensional constraint sits on an ordinary layer instead. +pub(crate) const DYNAMIC_DIMENSION_LAYER: &str = "*ADSK_CONSTRAINTS"; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, -/// 1 = value, 2 = name=value. -pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { - match format { +/// 1 = value, 2 = name=value, the value with its trailing zeros dropped +/// (`d9=80`, `d1=111.8034`). A parameter defined by a formula shows the +/// formula behind `fx:` (`fx: len=d1*2`); a reference (driven) constraint's +/// text sits in parentheses. +pub(crate) fn dynamic_dimension_text( + name: &str, + value: f64, + format: u8, + reference: bool, + expression: Option<&str>, +) -> String { + let formula = expression + .map(str::trim) + .filter(|source| !source.is_empty() && source.parse::().is_err()); + let shown = match formula { + Some(source) => source.to_string(), + None => { + let text = format!("{value:.4}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + if text.is_empty() || text == "-" { + "0".to_string() + } else { + text.to_string() + } + } + }; + let text = match format { 0 => name.to_string(), - 1 => format!("{value:.4}"), - _ => format!("{name}={value:.4}"), + 1 => shown, + _ => format!("{name}={shown}"), + }; + let text = if formula.is_some() { + format!("fx: {text}") + } else { + text + }; + if reference { + format!("({text})") + } else { + text } } +/// Screen height of a dynamic dimension's text and its arrows, in pixels — +/// the reference keeps them readable at any zoom. +const DYNAMIC_TEXT_PX: f32 = 12.0; +const DYNAMIC_ARROW_PX: f32 = 8.0; + /// The expression a measured value seeds a parameter with: twelve /// significant digits with the trailing zeros dropped (`100`, /// `111.803398875`), as the reference stores it. @@ -1593,8 +1636,11 @@ impl super::Scene { pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { let format = self.constraint_name_format; let mut updates: Vec<(Handle, String, Option<(Vector3, Vector3)>)> = Vec::new(); + // A reference (driven) constraint's parameter follows the geometry. + let mut followed: Vec<(String, f64)> = Vec::new(); for set in &self.parametric_constraints { - let table = if set.local_parameters.is_empty() { + let model_table = set.local_parameters.is_empty(); + let table = if model_table { &self.named_parameters } else { &set.local_parameters @@ -1603,13 +1649,6 @@ impl super::Scene { let Some(constraint) = set.get(*id) else { continue; }; - let (name, value) = match &constraint.driving_param { - Some(DrivingValue::Named(name)) => { - (name.clone(), table.resolve(name).unwrap_or(f64::NAN)) - } - Some(DrivingValue::Literal(value)) => (String::new(), *value), - None => continue, - }; let world = |reference: &ParametricRef| { let entity = self.document.get_entity(reference.entity)?; resolve_point(entity, reference.marker?) @@ -1618,13 +1657,56 @@ impl super::Scene { [first, second, ..] => world(first).zip(world(second)), _ => None, }; + let reference = !constraint.enabled; + let measured = points.map(|(first, second)| { + let delta = second - first; + let axis = match self.document.get_entity(*dimension) { + Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(linear), + )) => Some((linear.rotation.cos(), linear.rotation.sin())), + _ => None, + }; + match (constraint.kind, axis) { + (ConstraintKind::DistanceX, _) => delta.x.abs(), + (ConstraintKind::DistanceY, _) => delta.y.abs(), + (ConstraintKind::DistanceDirected, Some((ax, ay))) => { + (delta.x * ax + delta.y * ay).abs() + } + _ => delta.length(), + } + }); + let (name, value, source) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => { + let value = match (reference, measured) { + (true, Some(measured)) => { + if model_table { + followed.push((name.clone(), measured)); + } + measured + } + _ => table.resolve(name).unwrap_or(f64::NAN), + }; + let source = (!reference) + .then(|| table.get(name).map(|parameter| parameter.source.clone())) + .flatten(); + (name.clone(), value, source) + } + Some(DrivingValue::Literal(value)) => (String::new(), *value, None), + None => continue, + }; updates.push(( *dimension, - dynamic_dimension_text(&name, value, format), + dynamic_dimension_text(&name, value, format, reference, source.as_deref()), points, )); } } + for (name, measured) in followed { + let source = measured_expression(measured); + if self.named_parameters.get(&name).map(|p| p.source.as_str()) != Some(source.as_str()) { + let _ = self.named_parameters.set(&name, &source); + } + } for (handle, text, points) in updates { let Some(acadrust::EntityType::Dimension(mut dimension)) = self.document.get_entity(handle).cloned() @@ -1717,6 +1799,63 @@ impl super::Scene { ids.len() } + /// True for a dynamic-form dimensional constraint dimension — the ones + /// on the reference's constraints layer, which never plot; an + /// annotational one on an ordinary layer plots like any dimension. + pub(crate) fn is_plot_hidden_dynamic_dimension(&self, handle: Handle) -> bool { + self.is_dynamic_dimension(handle) + && self + .document + .get_entity(handle) + .is_some_and(|entity| entity.common().layer == DYNAMIC_DIMENSION_LAYER) + } + + /// Keeps dynamic dimensions readable at any zoom: their text and arrows + /// measure a fixed screen size, as the reference draws them. Runs every + /// tick and touches an entity only when the zoom moved it off size. + pub(crate) fn refresh_dynamic_dimension_sizes(&mut self) { + use crate::entities::dim_override as dov; + let Some(wpp) = self.world_per_pixel() else { + return; + }; + let text_height = (DYNAMIC_TEXT_PX * wpp) as f64; + let arrow = (DYNAMIC_ARROW_PX * wpp) as f64; + let handles: Vec = self + .parametric_constraints + .iter() + .flat_map(|set| set.dimensions.values().copied()) + .collect(); + for handle in handles { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + // An annotational dimension keeps its style's sizes. + if entity.common().layer != DYNAMIC_DIMENSION_LAYER { + continue; + } + let current = dov::real(&entity.common().extended_data, dov::DIMTXT); + if current.is_some_and(|height| (height - text_height).abs() <= text_height * 0.02) { + continue; + } + let mut entity = entity.clone(); + dov::set_on_entity(&mut entity, dov::DIMSCALE, Some(acadrust::xdata::XDataValue::Real(1.0))); + dov::set_on_entity(&mut entity, dov::DIMTXT, Some(acadrust::xdata::XDataValue::Real(text_height))); + dov::set_on_entity(&mut entity, dov::DIMASZ, Some(acadrust::xdata::XDataValue::Real(arrow))); + self.update_entity(entity); + } + } + + /// Makes sure the reference's dynamic dimension layer exists. + pub(crate) fn ensure_dynamic_dimension_layer(&mut self) { + if self.document.layers.contains(DYNAMIC_DIMENSION_LAYER) { + return; + } + let mut layer = acadrust::tables::Layer::new(DYNAMIC_DIMENSION_LAYER); + layer.color = acadrust::types::Color::from_index(7); + layer.handle = self.document.allocate_handle(); + let _ = self.document.layers.add(layer); + } + /// True for a dimension that shows a dimensional constraint. pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { self.parametric_constraints diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 61753cb4a..ba7660548 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1625,10 +1625,13 @@ fn build_constraint( distance_direction_type::PARALLEL_TO_LINE | distance_direction_type::PERPENDICULAR_TO_LINE ) { - if let Some(line) = direction_ref - .first() - .and_then(|reference| whole_line(sys, cache, *reference)) - { + if let Some(line) = direction_ref.first().and_then(|reference| { + // A text baseline or an ellipse axis directs the + // distance the same way a line does. + whole_line(sys, cache, *reference).or_else(|| { + directional_line(sys, cache, *reference).map(|(line, _)| line) + }) + }) { let current = { let store = sys.store(); let delta = [ @@ -3808,6 +3811,24 @@ impl Scene { self.parametric_constraints[i].dof = None; self.parametric_constraints[i].conflicts.clear(); } + // A dimensional constraint set to zero means the collapse. + let zero_collapse: HashSet = { + let set = &self.parametric_constraints[i]; + let params = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; + set.constraints + .iter() + .filter(|c| { + c.enabled + && c.kind == ConstraintKind::Distance + && resolved_target(params, c).is_some_and(|v| v.abs() <= f64::EPSILON) + }) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect() + }; for (handle, new_entity) in solved { // An edit the constraints can only satisfy by collapsing the // entity (a rotated line whose start is fixed and direction @@ -3816,7 +3837,8 @@ impl Scene { let new_entity = match originals.get(&handle) { Some(original) if collapsed_by_solve(&new_entity) - && !collapsed_by_solve(original.as_ref()) => + && !collapsed_by_solve(original.as_ref()) + && !zero_collapse.contains(&handle) => { original.as_ref().clone() } diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index bf2ee1d9f..b1aba4906 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -206,6 +206,8 @@ impl LayerPanel { self.layers = doc_layers .iter() + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|l| !l.name.starts_with('*')) .map(|l| { let layer_handle = l.handle; let vp_frozen = vp_info