diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b5bd1a2d0..34bf391e7 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4442,6 +4442,172 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddRadialConstraint { + circle, + location, + name, + expression, + renamed, + } => { + use crate::scene::named_parameters::{is_valid_name, DrivingValue}; + use crate::scene::parametric_constraints::{dynamic_dimension_text, ConstraintKind}; + + let scope = self.tabs[i].current_parametric_scope(); + let kind = ConstraintKind::Radius; + let refs = vec![circle]; + // A second radius on the same circle 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 == refs) + }); + 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(); + } + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + Some(&DrivingValue::Literal(1.0)), + ) { + self.reprompt_active_command(i, message); + return Task::none(); + } + let name = name.trim().to_string(); + if !is_valid_name(&name) { + self.command_line.push_error( + "Only alphanumeric names, starting with alpha characters, are allowed.", + ); + self.reprompt_active_command(i, &format!("Invalid parameter name: {name}.")); + return Task::none(); + } + if renamed && self.tabs[i].scene.named_parameters().contains(&name) { + self.reprompt_active_command(i, "Parameter with this name already exists."); + return Task::none(); + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&name, &expression) { + self.reprompt_active_command(i, &error.to_string()); + return Task::none(); + } + // A negative radius is its magnitude (`rad1=-20` is a radius + // of 20); a zero radius has no circle. + let value = match table.resolve(&name) { + Ok(value) if value.is_finite() && value != 0.0 => value, + _ => { + 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(); + } + }; + let annotational = self.constraint_form_annotational; + let text = dynamic_dimension_text( + &name, + value, + self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), + annotational, + None, + ); + let source = self.tabs[i] + .scene + .document + .get_entity(circle.entity) + .and_then(|entity| { + crate::scene::dimension_assoc::radial_source_at( + entity, + acadrust::types::Vector3::new(location.x, location.y, location.z), + ) + }); + let Some(source) = source else { + self.reprompt_active_command( + i, + crate::modules::parametric::DimConstraintCommand::NO_OBJECT, + ); + return Task::none(); + }; + let mut entity = crate::modules::annotate::radius_dim::radial_dimension_entity( + source, + location, + Some(text), + None, + ); + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + if annotational { + entity + .as_entity_mut() + .set_layer(self.tabs[i].active_layer.clone()); + } else { + 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, + b: 118, + }; + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + // The center stays; a value other than the measured radius + // resizes the circle about it. + let anchors = crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &refs, + ); + let pending = self.begin_undo(i, "Radius constraint", 2, false); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + let dimension = self.tabs[i].scene.add_entity(entity); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + set.dimensions.insert(id, dimension); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + self.tabs[i] + .scene + .attach_dimension_association(dimension, vec![Some(circle.entity)]); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &[(circle.entity, crate::scene::ChangeKind::Modified)], + &anchors, + false, + ); + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::MakeParallel { first_line, first_ends, @@ -5886,6 +6052,16 @@ impl OpenCADStudio { CmdResult::Dispatch(cmd) => { // End this interactive front-end, then run the assembled command // through the normal dispatcher. Selection is left untouched. + // DIMCONSTRAINT's Radius option becomes its next default; the + // DCRADIUS command run on its own leaves the default alone. + if cmd == "DCRADIUS" + && self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "DIMCONSTRAINT") + { + self.dim_constraint_last = "Radius"; + } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 26344dbad..f20cb886e 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1891,10 +1891,20 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(command)); } - "DCONSTRAINT" | "DCRADIUS" | "DCDIAMETER" => { - if cmd == "DCRADIUS" { - self.dim_constraint_last = "Radius"; - } else if cmd == "DCDIAMETER" { + "DCRADIUS" => { + use crate::modules::parametric::{DimConstraintAxis, DimConstraintCommand}; + use crate::scene::parametric_constraints::next_radial_parameter_name; + // Run on its own it leaves DIMCONSTRAINT's default alone. + let name = next_radial_parameter_name(self.tabs[i].scene.named_parameters()); + // The circle or arc is picked inside the command. + self.tabs[i].scene.deselect_all(); + let command = DimConstraintCommand::new(DimConstraintAxis::Radius, name); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "DCONSTRAINT" | "DCDIAMETER" => { + if cmd == "DCDIAMETER" { self.dim_constraint_last = "Diameter"; } let handles = self.tabs[i].scene.selected_handles_in_order(); diff --git a/src/app/properties.rs b/src/app/properties.rs index fd29ab28b..760d04324 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2368,6 +2368,9 @@ impl OpenCADStudio { acadrust::EntityType::Dimension( Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_), ) => t!("Angular Dimensional Constraint"), + acadrust::EntityType::Dimension(Dimension::Radius(_)) => { + t!("Radial Dimension (Dynamic)") + } _ => t!("Linear Dimensional Constraint"), } .into_owned() @@ -2850,6 +2853,13 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} ); } } + } else if matches!( + contextual.as_ref(), + acadrust::EntityType::Dimension(acadrust::entities::Dimension::Radius(_)) + ) && self.tabs[i].scene.is_dynamic_dimension(handle) + { + // A dynamic radius shows only its text square. + entity_grips.retain(|grip| grip.id == 2); } else 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 diff --git a/src/command.rs b/src/command.rs index 199017a5b..41d7c7a4e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1650,6 +1650,15 @@ pub enum CmdResult { expression: String, renamed: bool, }, + /// Adds a radius constraint on a circle or arc with its dynamic radial + /// dimension (text at `location`) and the `radN` parameter that drives it. + AddRadialConstraint { + circle: crate::scene::parametric_constraints::ParametricRef, + location: DVec3, + name: String, + expression: String, + renamed: bool, + }, /// 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`. diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 661b8785a..593c8ad55 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1134,7 +1134,11 @@ impl Grippable for Dimension { // of the world origin, so it stays on the visible text and grabbable. let text = { let p = self.base().text_middle_point; - if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 { + // A dynamic radius constraint draws its text at mid-radius, not + // at the stored point; its grip sits on the drawn text. + let drawn_elsewhere = + matches!(self, Dimension::Radius(_)) && dynamic_constraint_dimension(self); + if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 && !drawn_elsewhere { dv3(&p) } else { dv3(&dimension_text_pos_f64(self, None, 2.5, 1.0)) @@ -4780,7 +4784,12 @@ fn dimension_geometry( // to the centre. Text outside: only a leader from the arc point to // the text, unless DIMTOFL asks for the inside line as well. if !jogged && !suppress.dim2 && (!text_is_outside || params.dimtofl) { - add_segment(&mut g.dim_lines, center, point); + if dynamic_constraint_dimension(dim) { + // The line passes behind the mid-radius text. + add_segment_with_text_break(&mut g.dim_lines, center, point, params.text_break); + } else { + add_segment(&mut g.dim_lines, center, point); + } } if text_is_outside && !suppress.dim2 { append_radial_leader(&mut g, point, text, ¶ms); @@ -7542,6 +7551,15 @@ fn stored_text_point(dim: &Dimension) -> Option { (p.x * p.x + p.y * p.y + p.z * p.z > 1e-16).then_some(p) } +/// A dimensional constraint's dynamic dimension: the reference keeps it on +/// its own hidden layer and draws it by its own rules. +fn dynamic_constraint_dimension(dim: &Dimension) -> bool { + dim.base() + .common + .layer + .eq_ignore_ascii_case(crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER) +} + /// The point on the circle a radial leader leaves from: the arc point of a /// radius, or whichever end of a diameter's chord is nearer the text. fn radial_leader_tip(dim: &Dimension, text: Vector3) -> Vector3 { @@ -7598,6 +7616,12 @@ fn dimension_text_pos_f64( if let Some(point) = stored_text_point(dim) { return match dim { + // A dynamic radius constraint draws its text at mid-radius on the + // dimension line, whatever text position the file stores (the + // picked location), as the reference does. + Dimension::Radius(d) if dynamic_constraint_dimension(dim) => { + (d.angle_vertex + d.definition_point) * 0.5 + } // DIMTMOVE 1 stores where the leader's hook starts: one arrow of // hook, a gap, then the text, all running away from the arc. // Measured on 39 such dimensions the stored point sits 2.4 text diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index e595aef72..204d07435 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -62,32 +62,40 @@ impl RadiusDimensionCommand { source: crate::scene::dimension_assoc::RadialSourceGeometry, world: DVec3, ) -> EntityType { - let plane = WorkingPlane::new( - DVec3::from_array(source.plane.origin), - DVec3::from_array(source.plane.x_axis), - DVec3::from_array(source.plane.y_axis), - ); - let center = plane.to_local(dvec(source.center_world())); - let chord = plane.to_local(dvec(source.chord_at(world.to_array()))); - let point = plane.to_local(world); - let mut dim = DimensionRadius::new(v3(center), v3(chord)); - dim.base.definition_point = v3(chord); - dim.base.text_middle_point = v3(point); - dim.base.insertion_point = v3(point); - dim.base.text_user_positioned = true; - dim.leader_length = chord.distance(point); - dim.base.actual_measurement = dim.measurement(); - crate::entities::dimension::set_dimension_text_override( - &mut dim.base, - self.text_override.clone(), - ); - if let Some(angle) = self.text_angle { - dim.base.text_rotation = angle; - } - plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))) + radial_dimension_entity(source, world, self.text_override.clone(), self.text_angle) } } +/// A radius dimension of `source` with its text at `world`: the dimension +/// line runs from the center through the text (DIMUPT). +pub(crate) fn radial_dimension_entity( + source: crate::scene::dimension_assoc::RadialSourceGeometry, + world: DVec3, + text_override: Option, + text_angle: Option, +) -> EntityType { + let plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let center = plane.to_local(dvec(source.center_world())); + let chord = plane.to_local(dvec(source.chord_at(world.to_array()))); + let point = plane.to_local(world); + let mut dim = DimensionRadius::new(v3(center), v3(chord)); + dim.base.definition_point = v3(chord); + dim.base.text_middle_point = v3(point); + dim.base.insertion_point = v3(point); + dim.base.text_user_positioned = true; + dim.leader_length = chord.distance(point); + dim.base.actual_measurement = dim.measurement(); + crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override); + if let Some(angle) = text_angle { + dim.base.text_rotation = angle; + } + plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))) +} + impl CadCommand for RadiusDimensionCommand { fn set_working_plane(&mut self, _plane: WorkingPlane) { } diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index 63fc1df6b..8fda9bdfa 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -16,6 +16,8 @@ pub enum DimConstraintAxis { Aligned, /// The angle between two lines, or at a vertex between two points. Angular, + /// The radius of a circle or arc. + Radius, } /// A picked line (a line entity or one polyline segment) an Aligned @@ -106,6 +108,16 @@ enum Step { sector: u8, measured: f64, }, + /// Radius: `Select arc or circle:` + RadialSelect, + /// `Specify dimension line location:` of a radius. + RadialLocation { circle: ParametricRef, radius: f64 }, + /// `Enter value or name and value :` + RadialValue { + circle: ParametricRef, + radius: f64, + location: DVec3, + }, /// `Enter value or name and value :` Value { first: ParametricRef, @@ -155,14 +167,16 @@ impl DimConstraintCommand { "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 const PARALLEL_LINES: &'static str = "Lines are parallel."; + pub const INVALID_RADIAL: &'static str = + "Invalid selection for Radial. Select a circle or arc."; pub fn new(axis: DimConstraintAxis, default_name: String) -> Self { Self { axis, - step: if axis == DimConstraintAxis::Angular { - Step::AngularFirst - } else { - Step::First + step: match axis { + DimConstraintAxis::Angular => Step::AngularFirst, + DimConstraintAxis::Radius => Step::RadialSelect, + _ => Step::First, }, picked_entity: None, default_name, @@ -304,6 +318,25 @@ impl DimConstraintCommand { }) } + fn build_radial(&self, name: String, expression: String) -> Option { + let Step::RadialValue { + circle, location, .. + } = self.step + else { + return None; + }; + if expression.trim().is_empty() { + return None; + } + Some(CmdResult::AddRadialConstraint { + circle, + location, + renamed: name != self.default_name, + name, + expression, + }) + } + fn command_name(&self) -> &'static str { match self.axis { DimConstraintAxis::Linear => "DCLINEAR", @@ -311,6 +344,7 @@ impl DimConstraintCommand { DimConstraintAxis::Vertical => "DCVERTICAL", DimConstraintAxis::Aligned => "DCALIGNED", DimConstraintAxis::Angular => "DCANGULAR", + DimConstraintAxis::Radius => "DCRADIUS", } } @@ -321,6 +355,7 @@ impl DimConstraintCommand { DimConstraintAxis::Vertical => "Vertical", DimConstraintAxis::Aligned => "Aligned", DimConstraintAxis::Angular => "Angular", + DimConstraintAxis::Radius => "Radial", } } @@ -474,8 +509,8 @@ impl DimConstraintCommand { } DimConstraintAxis::Horizontal => (ConstraintKind::DistanceX, DVec3::X), DimConstraintAxis::Vertical => (ConstraintKind::DistanceY, DVec3::Y), - // Angular never measures here; its own steps decide the sector. - DimConstraintAxis::Aligned | DimConstraintAxis::Angular => ( + // Angular and Radius never measure here; their own steps decide. + DimConstraintAxis::Aligned | DimConstraintAxis::Angular | DimConstraintAxis::Radius => ( ConstraintKind::Distance, (second - first).normalize_or(DVec3::X), ), @@ -525,7 +560,9 @@ impl DimConstraintCommand { DimConstraintAxis::Linear => "Linear constraint", DimConstraintAxis::Horizontal => "Horizontal distance constraint", DimConstraintAxis::Vertical => "Vertical distance constraint", - DimConstraintAxis::Aligned | DimConstraintAxis::Angular => "Aligned constraint", + DimConstraintAxis::Aligned | DimConstraintAxis::Angular | DimConstraintAxis::Radius => { + "Aligned constraint" + } }, }) } @@ -567,14 +604,17 @@ impl CadCommand for DimConstraintCommand { Step::AngularPoint2 { .. } => { format!("{name} Specify second angle constraint point:") } - Step::AngularLocation { .. } | Step::Location { .. } => { + Step::RadialSelect => format!("{name} Select arc or circle:"), + Step::AngularLocation { .. } | Step::Location { .. } | Step::RadialLocation { .. } => { format!("{name} Specify dimension line location:") } - Step::AngularValue { measured, .. } => format!( - "{name} Enter value or name and value <{}={}>:", - self.default_name, - measured_expression(measured) - ), + Step::AngularValue { measured, .. } | Step::RadialValue { radius: measured, .. } => { + format!( + "{name} Enter value or name and value <{}={}>:", + self.default_name, + measured_expression(measured) + ) + } Step::Value { measured, .. } => format!( "{name} Enter value or name and value <{}={}>:", self.default_name, @@ -609,6 +649,7 @@ impl CadCommand for DimConstraintCommand { | Step::Value { .. } | Step::AngularFirst | Step::AngularValue { .. } + | Step::RadialValue { .. } ) } @@ -643,16 +684,16 @@ impl CadCommand for DimConstraintCommand { } None } - Step::Value { .. } | Step::AngularValue { .. } => { + Step::Value { .. } | Step::AngularValue { .. } | Step::RadialValue { .. } => { let text = text.trim(); let (name, expression) = match text.split_once('=') { Some((name, expression)) => (name.trim().to_string(), expression.trim().to_string()), None => (self.default_name.clone(), text.to_string()), }; - if matches!(self.step, Step::AngularValue { .. }) { - self.build_angular(name, expression) - } else { - self.build(name, expression) + match self.step { + Step::AngularValue { .. } => self.build_angular(name, expression), + Step::RadialValue { .. } => self.build_radial(name, expression), + _ => self.build(name, expression), } } _ => None, @@ -676,6 +717,7 @@ impl CadCommand for DimConstraintCommand { | Step::AngularVertex | Step::AngularPoint1 { .. } | Step::AngularPoint2 { .. } + | Step::RadialSelect ) } @@ -867,10 +909,35 @@ impl CadCommand for DimConstraintCommand { whole_curve: false, }) } + Step::RadialSelect => { + if handle.is_null() { + return Self::report(Self::NO_OBJECT); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let radius = match &entity { + EntityType::Circle(circle) => circle.radius, + EntityType::Arc(arc) => arc.radius, + _ => return Self::report(Self::INVALID_RADIAL), + }; + self.step = Step::RadialLocation { + circle: ParametricRef::whole(handle), + radius, + }; + // The reference reports the radius before asking where the + // dimension goes. + CmdResult::ReportMeasurement(format!( + "Dimension text = {}", + measured_expression(radius) + )) + } Step::Location { .. } | Step::Value { .. } | Step::AngularLocation { .. } - | Step::AngularValue { .. } => self.on_point(point), + | Step::AngularValue { .. } + | Step::RadialLocation { .. } + | Step::RadialValue { .. } => self.on_point(point), } } @@ -998,7 +1065,16 @@ impl CadCommand for DimConstraintCommand { | Step::TwoLinesFirst | Step::TwoLinesSecond { .. } | Step::AngularFirst - | Step::AngularSecond { .. } => Self::report(Self::NO_OBJECT), + | Step::AngularSecond { .. } + | Step::RadialSelect => Self::report(Self::NO_OBJECT), + Step::RadialLocation { circle, radius } => { + self.step = Step::RadialValue { + circle, + radius, + location: point, + }; + CmdResult::NeedPoint + } Step::AngularLocation { data } => { let frame = match data { AngularData::Lines { first, second } => Self::angle_frame( @@ -1050,9 +1126,10 @@ impl CadCommand for DimConstraintCommand { }; CmdResult::ReportMeasurement(format!("Dimension text = {measured:.4}")) } - Step::TwoLinesParallel { .. } | Step::Value { .. } | Step::AngularValue { .. } => { - CmdResult::NeedPoint - } + Step::TwoLinesParallel { .. } + | Step::Value { .. } + | Step::AngularValue { .. } + | Step::RadialValue { .. } => CmdResult::NeedPoint, } } @@ -1070,6 +1147,9 @@ impl CadCommand for DimConstraintCommand { Step::AngularValue { measured, .. } => self .build_angular(self.default_name.clone(), measured_expression(measured)) .unwrap_or(CmdResult::Cancel), + Step::RadialValue { radius, .. } => self + .build_radial(self.default_name.clone(), measured_expression(radius)) + .unwrap_or(CmdResult::Cancel), Step::PointLinePoint => { self.step = Step::LineFirst; CmdResult::NeedPoint diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index bd432ed06..75a885496 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -123,10 +123,14 @@ impl CadModule for ParametricModule { default: "DCLINEAR", }, RibbonItem::LargeTool(dimensional_tools::aligned()), - RibbonItem::LargeTool(dimensional_tools::radius()), - RibbonItem::LargeTool(dimensional_tools::diameter()), - RibbonItem::LargeTool(dimensional_tools::angular()), - RibbonItem::LargeTool(dimensional_tools::convert()), + // Two rows of small icon-only buttons, as in the + // reference: Radius, Diameter / Angular, Convert. + RibbonItem::ToolGrid { + columns: vec![ + vec![dimensional_tools::radius(), dimensional_tools::angular()], + vec![dimensional_tools::diameter(), dimensional_tools::convert()], + ], + }, RibbonItem::LabeledDropdown { id: "DCVISIBILITY", label: "Show/Hide", icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/show.svg")), @@ -200,10 +204,17 @@ mod tests { ); assert_eq!( groups[1].tools.iter().map(item_id).collect::>(), - [ - "DC_LINEAR_MENU", "DCALIGNED", "DCRADIUS", "DCDIAMETER", - "DCANGULAR", "DCCONVERT", "DCVISIBILITY", "DCSHOWALL", "DCHIDEALL", - ] + ["DC_LINEAR_MENU", "DCALIGNED", "GRID", "DCVISIBILITY", "DCSHOWALL", "DCHIDEALL"] + ); + let RibbonItem::ToolGrid { columns } = &groups[1].tools[2] else { + panic!("radius, diameter, angular and convert must be a small-button grid"); + }; + assert_eq!( + columns + .iter() + .map(|column| column.iter().map(|tool| tool.id).collect::>()) + .collect::>(), + [["DCRADIUS", "DCANGULAR"], ["DCDIAMETER", "DCCONVERT"]] ); assert_eq!(groups[2].tools.iter().map(item_id).collect::>(), ["DELCONSTRAINT", "PARAMETERS"]); @@ -218,8 +229,6 @@ mod tests { assert!(groups[0].tools[1..13] .iter() .all(|item| matches!(item, RibbonItem::LargeTool(_)))); - assert!(groups[1].tools[2..6] - .iter() - .all(|item| matches!(item, RibbonItem::LargeTool(_)))); + assert!(matches!(groups[1].tools[1], RibbonItem::LargeTool(_))); } } diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 0b8cbe752..160ae481b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -353,24 +353,36 @@ pub(crate) fn equal_size_follower( Some(entity) } +/// The first free `1`, `2`, … name a new constraint takes. +fn next_prefixed_parameter_name( + table: &super::named_parameters::ParameterTable, + prefix: &str, +) -> String { + (1..) + .map(|n| format!("{prefix}{n}")) + .find(|name| !table.contains(name)) + .unwrap_or_else(|| format!("{prefix}1")) +} + /// The first free `d1`, `d2`, … name a new dimensional constraint takes. pub(crate) fn next_dimensional_parameter_name( table: &super::named_parameters::ParameterTable, ) -> String { - (1..) - .map(|n| format!("d{n}")) - .find(|name| !table.contains(name)) - .unwrap_or_else(|| "d1".to_string()) + next_prefixed_parameter_name(table, "d") } /// The first free `ang1`, `ang2`, … name a new angular constraint takes. pub(crate) fn next_angular_parameter_name( table: &super::named_parameters::ParameterTable, ) -> String { - (1..) - .map(|n| format!("ang{n}")) - .find(|name| !table.contains(name)) - .unwrap_or_else(|| "ang1".to_string()) + next_prefixed_parameter_name(table, "ang") +} + +/// The first free `rad1`, `rad2`, … name a new radius constraint takes. +pub(crate) fn next_radial_parameter_name( + table: &super::named_parameters::ParameterTable, +) -> String { + next_prefixed_parameter_name(table, "rad") } /// An angle as the reference displays it: a negative or over-full value is @@ -565,6 +577,52 @@ fn dynamic_dimension_follow_points( true } +/// A circle's or arc's center and radius. +fn radial_geometry(entity: &acadrust::EntityType) -> Option<(Vector3, f64)> { + match entity { + acadrust::EntityType::Circle(circle) => Some((circle.center_wcs(), circle.radius)), + acadrust::EntityType::Arc(arc) => Some((arc.center_wcs(), arc.radius)), + _ => None, + } +} + +/// Moves a dynamic radial dimension onto its circle's current center and +/// radius: the dimension line keeps its direction and the text keeps its +/// distance beyond the circle, as in the reference; true when it moved. +fn dynamic_dimension_follow_radius( + dimension: &mut acadrust::entities::Dimension, + center: Vector3, + radius: f64, +) -> bool { + let acadrust::entities::Dimension::Radius(radial) = dimension else { + return false; + }; + let to_dvec = |p: Vector3| glam::DVec3::new(p.x, p.y, p.z); + let to_vec = |p: glam::DVec3| Vector3::new(p.x, p.y, p.z); + let old_center = to_dvec(radial.angle_vertex); + let old_radius = (to_dvec(radial.definition_point) - old_center).length(); + let center = to_dvec(center); + if (old_center - center).length_squared() < 1.0e-16 && (old_radius - radius).abs() < 1.0e-9 { + return false; + } + let toward_text = to_dvec(radial.base.text_middle_point) - old_center; + let direction = toward_text + .try_normalize() + .or_else(|| (to_dvec(radial.definition_point) - old_center).try_normalize()) + .unwrap_or(glam::DVec3::X); + let beyond = toward_text.length() - old_radius; + let chord = center + direction * radius; + let text = center + direction * (radius + beyond); + radial.angle_vertex = to_vec(center); + radial.definition_point = to_vec(chord); + radial.base.definition_point = to_vec(chord); + radial.base.text_middle_point = to_vec(text); + radial.base.insertion_point = to_vec(text); + radial.leader_length = (text - chord).length(); + radial.base.actual_measurement = radius; + true +} + /// The end points of a line reference: a line's two ends, or the picked /// polyline segment's vertices. fn line_ends(entity: &acadrust::EntityType, reference: ParametricRef) -> Option<(Vector3, Vector3)> { @@ -673,6 +731,11 @@ pub(crate) fn dimensional_anchor_refs( let Some(&first) = refs.first() else { return Vec::new(); }; + // A whole circle or arc (a radius or diameter): its center stays and the + // value resizes it. + if refs.len() == 1 && first.marker.is_none() { + return vec![ParametricRef::center(first.entity)]; + } // Three point references: a three-point angle `[first, vertex, second]`. if refs.len() == 3 && refs @@ -1983,6 +2046,7 @@ impl super::Scene { String, Option<(Vector3, Vector3)>, Option>, + Option<(Vector3, f64)>, )> = Vec::new(); // A reference (driven) constraint's parameter follows the geometry. let mut followed: Vec<(String, f64)> = Vec::new(); @@ -2010,7 +2074,15 @@ impl super::Scene { constraint.kind, ConstraintKind::Angle | ConstraintKind::Angle3Point ); - let measured = points.filter(|_| !angular).map(|(first, second)| { + // A radius: the circle's or arc's center and radius now. + let radial = (constraint.kind == ConstraintKind::Radius) + .then(|| { + let reference = constraint.refs.first()?; + radial_geometry(self.document.get_entity(reference.entity)?) + }) + .flatten(); + let measured = radial.map(|(_, radius)| radius); + let measured = measured.or(points.filter(|_| !angular).map(|(first, second)| { let delta = second - first; let axis = match self.document.get_entity(*dimension) { Some(acadrust::EntityType::Dimension( @@ -2026,7 +2098,7 @@ impl super::Scene { } _ => delta.length(), } - }); + })); let (name, value, source) = match &constraint.driving_param { Some(DrivingValue::Named(name)) => { let value = match (reference, measured) { @@ -2070,6 +2142,7 @@ impl super::Scene { ), points, angle_points, + radial, )); } } @@ -2079,7 +2152,7 @@ impl super::Scene { let _ = self.named_parameters.set(&name, &source); } } - for (handle, text, points, angle_points) in updates { + for (handle, text, points, angle_points, radial) in updates { let Some(acadrust::EntityType::Dimension(mut dimension)) = self.document.get_entity(handle).cloned() else { @@ -2096,6 +2169,11 @@ impl super::Scene { changed = true; } } + if let Some((center, radius)) = radial { + if dynamic_dimension_follow_radius(&mut dimension, center, radius) { + changed = true; + } + } if dimension.base().user_text.as_deref() != Some(text.as_str()) { crate::entities::dimension::set_dimension_text_override( dimension.base_mut(), diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 1bf3104c3..63b71a19a 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -996,14 +996,15 @@ fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) - // 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()); + // A radius or diameter likewise (`rad1=-20` is a radius of 20); zero is + // no circle at all. + match constraint.kind { + ConstraintKind::Distance => Some(value.abs()), + ConstraintKind::Radius | ConstraintKind::Diameter => { + (value != 0.0).then(|| value.abs()) + } + _ => Some(value), } - let must_be_positive = matches!( - constraint.kind, - ConstraintKind::Radius | ConstraintKind::Diameter - ); - (!must_be_positive || value > 0.0).then_some(value) } fn oriented_distance(target: f64, current_projection: f64) -> f64 { diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index b1a977a39..72b765fa2 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -480,6 +480,30 @@ pub(super) fn make_tip(tip: String) -> Element<'static, Message> { text(tip).size(11).into() } +/// The sentence the reference shows under a tool's name, where known. +fn tool_description(id: &str) -> Option<&'static str> { + Some(match id { + "DCLINEAR" => "Constrains the horizontal or vertical distance between points", + "DCHORIZONTAL" => "Constrains the X-distance between points on an object, or between two points on different objects", + "DCVERTICAL" => "Constrains the Y-distance between points on an object, or between two points on different objects", + "DCALIGNED" => "Constrains the distance between two points on an object or between two points on different objects", + "DCRADIUS" => "Constrains the radius of a circle or arc", + "DCDIAMETER" => "Constrains the diameter of a circle or arc", + "DCANGULAR" => "Constrains the angle between line or polyline segments, the angle swept out by an arc or a polyline arc segment, or the angle between three points on objects", + "DCCONVERT" => "Convert dimensions to dimensional constraints", + _ => return None, + }) +} + +/// A tool's tooltip: its name, its description when one is known, and the +/// command it runs. +fn tool_tip_text(label: &str, id: &str) -> String { + match tool_description(id) { + Some(description) => format!("{label}\n{}\n{} {id}", t!(description), t!("Command:")), + None => format!("{label}\n{} {id}", t!("Command:")), + } +} + pub(super) fn tip_style(theme: &Theme) -> container::Style { let palette = theme.palette(); container::Style { @@ -511,7 +535,7 @@ pub(super) fn render_small<'a>( let active = is_active_tool(t.id, active_tool, &state); let event = t.event.clone(); let tool_id = t.id.to_string(); - let tip_text = format!("{}\n{} {}", t!(t.label), t!("Command:"), t.id); + let tip_text = tool_tip_text(&t!(t.label), t.id); let btn = button(make_icon(t.icon, SMALL_ICON)) .on_press(Message::RibbonToolClick { tool_id, event }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) @@ -530,7 +554,7 @@ pub(super) fn render_small<'a>( let event = t.event.clone(); let tool_id = t.id.to_string(); let label = t!(t.label).into_owned(); - let tip_text = format!("{}\n{} {}", label, t!("Command:"), t.id); + let tip_text = tool_tip_text(&label, t.id); let content = row![ container(make_icon(t.icon, SMALL_ICON)).width(Length::Fixed(SMALL_W)), text(label).size(10).wrapping(advanced_text::Wrapping::None), @@ -588,7 +612,7 @@ pub(super) fn render_small<'a>( }) .or_else(|| items.first().map(|(_, lbl, _)| *lbl)) .unwrap_or(*id); - let tip_text = format!("{}\n{} {}", t!(cur_label), t!("Command:"), last); + let tip_text = tool_tip_text(&t!(cur_label), last); let icon_btn = button(make_icon(cur_icon, SMALL_ICON)) .on_press(Message::RibbonToolClick { @@ -658,7 +682,7 @@ pub(super) fn render_small<'a>( .on_press(Message::ToggleRibbonDropdown(id.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, dd_open, status)) .width(Length::Fixed(ARROW_W)).height(ROW_H).padding(0); - let face_tip = format!("{}\n{} {}", localized_label, t!("Command:"), last); + let face_tip = tool_tip_text(&localized_label, last); let arrow_tip = format!("{} {}", localized_label, t!("options")); PosReport::new(*id, row![ tooltip(face_btn, make_tip(face_tip), TipPos::Right) @@ -727,7 +751,7 @@ pub(super) fn render_large_dropdown<'a>( .or_else(|| items.first().map(|(_, lbl, _)| *lbl)) .unwrap_or(id); let label = t!(explicit_label.unwrap_or(cur_label)).into_owned(); - let tip_text = format!("{}\n{} {}", t!(cur_label), t!("Command:"), last); + let tip_text = tool_tip_text(&t!(cur_label), last); let arr_tip = format!("{} {}", label, t!("options")); // The label owns the bottom of the face. The icon's Fill container centers @@ -864,7 +888,7 @@ pub(super) fn render_large<'a>( let event = t.event.clone(); let tool_id = t.id.to_string(); let label = t!(t.label).into_owned(); - let tip_text = format!("{}\n{} {}", label, t!("Command:"), t.id); + let tip_text = tool_tip_text(&label, t.id); let btn = button( column![ container(make_icon(t.icon, LARGE_ICON))