fix(properties): preserve literal text

Separate numeric fields from names and content so only numeric property values are evaluated as expressions.

Refs #809
This commit is contained in:
Hakan Seven committed 2026-08-20 14:58:12 +03:00
commit 5e62da464e
15 files changed
+107 -31

No files matched your search

+55
View File
@@ -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");
+11 -5
View File
@@ -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())
}
+13 -6
View File
@@ -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<Message> {
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