diff --git a/src/app/expr_eval.rs b/src/app/expr_eval.rs index 24f5b18f4..7e6163d52 100644 --- a/src/app/expr_eval.rs +++ b/src/app/expr_eval.rs @@ -433,6 +433,9 @@ pub fn eval_to_string(input: &str) -> String { let mut found = false; // Try longest substring first (greedy) for end in (i + 1..=len).rev() { + if !starts_and_ends_on_word_boundary(&chars, i, end) { + continue; + } let sub: String = chars[i..end].iter().collect(); if let Some(v) = eval_number(&sub) { result.push_str(&format!("{}", v)); @@ -449,6 +452,37 @@ pub fn eval_to_string(input: &str) -> String { result } +/// Rejects expression candidates cut from a surrounding identifier. +fn starts_and_ends_on_word_boundary(chars: &[char], start: usize, end: usize) -> bool { + fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' + } + fn starts_ident(c: char) -> bool { + c.is_alphabetic() || c == '_' + } + + // First token is an identifier: a word character just before it means this + // is the tail of a longer word rather than a name of its own. + if starts_ident(chars[start]) && start > 0 && is_word(chars[start - 1]) { + return false; + } + + // Last token is an identifier when the trailing run of word characters + // begins with a letter or `_`. A word character just after `end` means that + // identifier continues past the candidate. + if end < chars.len() && is_word(chars[end]) { + let mut run = end; + while run > start && is_word(chars[run - 1]) { + run -= 1; + } + if run < end && starts_ident(chars[run]) { + return false; + } + } + + true +} + // ── Tests ─────────────────────────────────────────────────────────── #[cfg(test)] @@ -615,6 +649,27 @@ mod tests { assert_eq!(eval_to_string("abs(-5),ceil(3.1)"), "5,4"); } + #[test] + fn embedded_identifier_inside_a_word_is_left_alone() { + // Known names must stay unchanged inside identifiers. + assert_eq!(eval_to_string("ASPIRATORE"), "ASPIRATORE"); + assert_eq!(eval_to_string("PIRATE"), "PIRATE"); + assert_eq!(eval_to_string("copies"), "copies"); + assert_eq!(eval_to_string("TOPI"), "TOPI"); + assert_eq!(eval_to_string("pi_2"), "pi_2"); + assert_eq!(eval_to_string("şpiş"), "şpiş"); + assert_eq!(eval_to_string("копияpi"), "копияpi"); + // A standalone `pi` still is a constant. + assert_eq!( + eval_to_string("pipe,pi"), + format!("pipe,{}", std::f64::consts::PI) + ); + assert_eq!( + eval_to_string("2*pi,0"), + format!("{},0", 2.0 * std::f64::consts::PI) + ); + } + #[test] fn embedded_expr_no_evaluables() { assert_eq!(eval_to_string("abc,def"), "abc,def"); diff --git a/src/app/properties.rs b/src/app/properties.rs index e4dc29f93..15e92b0b8 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -837,7 +837,7 @@ impl OpenCADStudio { geom.props.push(crate::scene::model::object::Property { label: t!("Frozen Layers").into_owned(), field: "frozen_layers", - value: crate::scene::model::object::PropValue::EditText( + value: crate::scene::model::object::PropValue::PlainText( frozen_names.join(", "), ), }); @@ -903,15 +903,17 @@ impl OpenCADStudio { .filter(|n| !n.is_empty()) .collect(); if !dim_style_names.is_empty() { - // Current style is already shown as EditText in the geom section; + // Current style is already shown as text in the geom section; // replace/upgrade it to a Choice if we have a list. if let Some(geom) = sections.last_mut() { - // Find and replace the style_name EditText with a Choice. + // Replace the style name with a choice. if let Some(prop) = geom.props.iter_mut().find(|p| p.field == "style_name") { let current = match &prop.value { - crate::scene::model::object::PropValue::EditText(s) => s.clone(), + crate::scene::model::object::PropValue::PlainText(s) => { + s.clone() + } _ => String::new(), }; prop.value = crate::scene::model::object::PropValue::Choice { @@ -1044,7 +1046,7 @@ impl OpenCADStudio { .find(|p| p.field == "dimension_style") { let cur = match &p.value { - crate::scene::model::object::PropValue::EditText(s) => { + crate::scene::model::object::PropValue::PlainText(s) => { s.clone() } _ => ld.dimension_style.clone(), @@ -1979,6 +1981,7 @@ fn make_sections_read_only( let text = match &property.value { PropValue::ReadOnly(value) | PropValue::EditText(value) + | PropValue::PlainText(value) | PropValue::LayerChoice(value) | PropValue::LinetypeChoice(value) | PropValue::HatchPatternChoice(value) => value.clone(), @@ -2159,6 +2162,9 @@ fn merge_prop_value( (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } + (PropValue::PlainText(_), PropValue::PlainText(_)) => { + PropValue::PlainText(VARIES_LABEL.into()) + } (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 635931afc..28aa223b7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -14,7 +14,7 @@ use crate::scene::pick::grip::{ }; #[cfg(not(target_arch = "wasm32"))] use crate::plugin::v4_support; -use crate::scene::model::object::GripApply; +use crate::scene::model::object::{GripApply, PropValue}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -2168,17 +2168,24 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { + let evaluates_expression = self.tabs[i] + .properties + .sections + .iter() + .flat_map(|section| section.props.iter()) + .find(|property| property.field == field) + .is_some_and(|property| { + matches!(property.value, PropValue::EditText(_)) + }); if let Some(raw_val) = self.tabs[i] .properties .edit_buf .remove(&crate::ui::properties::FieldKey::Geom(field)) { - // Block names are free-form text — a name like "10-5" - // must not be arithmetic-evaluated. - let val = if field == "block" { - raw_val - } else { + let val = if evaluates_expression { crate::app::expr_eval::eval_to_string(&raw_val) + } else { + raw_val }; if matches!(field, "current_fit_point" | "current_control_point") { let count = handles diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index d74c8b1a3..dd5cc5394 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -417,17 +417,17 @@ impl PropertyEditable for AttributeDefinition { Property { label: t!("Tag").into_owned(), field: "att_tag", - value: PropValue::EditText(self.tag.clone()), + value: PropValue::PlainText(self.tag.clone()), }, Property { label: t!("Prompt").into_owned(), field: "att_prompt", - value: PropValue::EditText(self.prompt.clone()), + value: PropValue::PlainText(self.prompt.clone()), }, Property { label: t!("Value").into_owned(), field: "att_default", - value: PropValue::EditText(self.default_value.clone()), + value: PropValue::PlainText(self.default_value.clone()), }, Property { label: t!("Style").into_owned(), @@ -698,13 +698,13 @@ impl PropertyEditable for AttributeEntity { Property { label: t!("Tag").into_owned(), field: "atte_tag", - value: PropValue::EditText(self.tag.clone()), + value: PropValue::PlainText(self.tag.clone()), }, ro(t!("Prompt").as_ref(), "atte_prompt", String::new()), Property { label: t!("Value").into_owned(), field: "atte_val", - value: PropValue::EditText(self.value.clone()), + value: PropValue::PlainText(self.value.clone()), }, Property { label: t!("Style").into_owned(), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 204942df6..109d3fad2 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -19,19 +19,19 @@ fn base_props(base: &DimensionBase) -> Vec Property { Property { label: label.into(), field, - value: PropValue::EditText(value.to_string()), + value: PropValue::PlainText(value.to_string()), } } diff --git a/src/entities/leader.rs b/src/entities/leader.rs index f42f8abc1..aec16ce71 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -291,7 +291,7 @@ fn properties(leader: &Leader) -> Vec { Property { label: t!("Dim style").into_owned(), field: "dimension_style", - value: PropValue::EditText(leader.dimension_style.clone()), + value: PropValue::PlainText(leader.dimension_style.clone()), }, choice_prop( t!("Type").as_ref(), diff --git a/src/entities/mline.rs b/src/entities/mline.rs index 096016208..a1a2ea736 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -337,7 +337,7 @@ impl PropertyEditable for MLine { Property { label: t!("Style").into_owned(), field: "ml_style", - value: PropValue::EditText(self.style_name.clone()), + value: PropValue::PlainText(self.style_name.clone()), }, Property { label: t!("Style justification").into_owned(), diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 14796ac4f..5c6a46eb9 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -260,7 +260,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { Property { label: t!("Contents").into_owned(), field: "content", - value: PropValue::EditText(t.value.clone()), + value: PropValue::PlainText(t.value.clone()), }, Property { label: t!("Style").into_owned(), diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 25565c554..c2ade8eda 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -715,7 +715,7 @@ fn properties(ml: &MultiLeader) -> Vec { Property { label: t!("Contents").into_owned(), field: "text_string", - value: PropValue::EditText(ctx.text_string.clone()), + value: PropValue::PlainText(ctx.text_string.clone()), }, // Text-style name resolved from text_style_handle by the panel builder. ro(t!("Text style").as_ref(), "text_style_handle", "Standard"), diff --git a/src/entities/text.rs b/src/entities/text.rs index 00af2eea8..18a96fdad 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -279,7 +279,7 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { Property { label: t!("Contents").into_owned(), field: "content", - value: PropValue::EditText(t.value.clone()), + value: PropValue::PlainText(t.value.clone()), }, Property { label: t!("Style").into_owned(), diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 5bad362b1..66de0882f 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -86,7 +86,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { Property { label: t!("Hyperlink").into_owned(), field: "hyperlink", - value: PropValue::EditText(hyperlink), + value: PropValue::PlainText(hyperlink), }, ], }; diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 7a07d1d2c..f688072c1 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -8,8 +8,10 @@ use glam::DVec3; pub enum PropValue { /// Read-only display text. ReadOnly(String), - /// Editable numeric/text field. + /// Editable numeric field. EditText(String), + /// Editable text that must not be expression-evaluated. + PlainText(String), /// Layer name — rendered as a combo_box. LayerChoice(String), /// Generic string choice rendered as a combo_box. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index b073a1956..8c1af2d32 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -417,6 +417,7 @@ impl Scene { ]) } else { match prop.value { + PropValue::PlainText(_) => QSelectValueEditor::Text, PropValue::ReadOnly(ref value) | PropValue::EditText(ref value) => { let field = prop.field.to_ascii_lowercase(); @@ -614,7 +615,9 @@ impl Scene { .flat_map(|s| s.props) .find(|p| p.field == field)?; Some(match prop.value { - PropValue::ReadOnly(s) | PropValue::EditText(s) => s, + PropValue::ReadOnly(s) + | PropValue::EditText(s) + | PropValue::PlainText(s) => s, PropValue::LayerChoice(s) => s, PropValue::Choice { selected, .. } => selected, PropValue::EditChoice { value, .. } => value, diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 256bae3fb..118afec86 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -314,7 +314,9 @@ pub fn build_field_key_map( for section in sections { for prop in §ion.props { let key = match &prop.value { - PropValue::EditText(_) => Some(FieldKey::Geom(prop.field)), + PropValue::EditText(_) | PropValue::PlainText(_) => { + Some(FieldKey::Geom(prop.field)) + } PropValue::AttrText { tag, .. } => Some(FieldKey::Attr(tag.clone())), _ => None, }; @@ -765,7 +767,9 @@ impl PropertiesPanel { } PropValue::BoolToggle { field, value } => render_bool_row(label, *field, *value), PropValue::Stepper { display, .. } => render_stepper_row(label, display), - PropValue::EditText(val) => self.render_edit_row(label, prop.field, val), + PropValue::EditText(val) | PropValue::PlainText(val) => { + self.render_edit_row(label, prop.field, val) + } PropValue::ReadOnly(val) if prop.field == "annotative_scale" => { render_annotative_scale_row(label, val) }