From 4a3b4161038c7eed7ccb5809fba481e06bf2d697 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Thu, 3 Sep 2026 14:28:13 +0700 Subject: [PATCH 1/3] fix(dimension): carry engineering-unit inches up to feet format_engineering split feet from inches before rounding to DIMDEC, so a remainder that rounds up to a full foot printed out of range: 35.999" at two decimals read 2'-12.00" instead of 3'-0.00". A magnitude that rounded away to zero also kept a lone minus sign. Round on the decimal grid first and split afterwards, matching the carry handling already used by format_architectural and format_fractional. --- src/entities/dimension.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index c27fc87db..b7ccbb980 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6432,10 +6432,16 @@ fn format_with_unit( } fn format_engineering(inches: f64, dec: usize) -> String { - let sign = if inches < 0.0 { "-" } else { "" }; - let abs = inches.abs(); - let feet = (abs / 12.0).trunc(); - let rem_in = abs - feet * 12.0; + // Round to the printed precision *first*, then split, so inches that round + // up to a full foot carry into the feet instead of printing an + // out-of-range "2'-12.00"". Same reasoning as `format_architectural`, + // where the grid is the fraction denominator rather than a decimal place. + let scale = 10f64.powi(dec.min(15) as i32); + let ticks = (inches.abs() * scale).round(); + let per_foot = 12.0 * scale; + let sign = if inches < 0.0 && ticks != 0.0 { "-" } else { "" }; + let feet = (ticks / per_foot).trunc(); + let rem_in = (ticks - feet * per_foot) / scale; format!("{}{:.0}'-{:.*}\"", sign, feet, dec, rem_in) } @@ -7272,7 +7278,7 @@ mod dimtad_tests { #[cfg(test)] mod arch_format_tests { - use super::{format_architectural, format_fractional}; + use super::{format_architectural, format_engineering, format_fractional}; // A 3ft object that measures 35.99" (float noise) must carry the rounded // fraction up through inches into feet — not print "2'-11 1"". (Regression @@ -7305,4 +7311,27 @@ mod arch_format_tests { assert_eq!(format_fractional(11.999, 4, 2), "12"); assert_eq!(format_fractional(6.5, 4, 2), "6 1/2"); } + + // Engineering units round to DIMDEC before the feet/inches split, so a + // remainder that rounds up to 12" carries instead of printing "2'-12.00"". + #[test] + fn engineering_carries_inches_up_to_feet() { + assert_eq!(format_engineering(35.999, 2), "3'-0.00\""); + assert_eq!(format_engineering(11.999, 2), "1'-0.00\""); + assert_eq!(format_engineering(11.9, 0), "1'-0\""); + } + + #[test] + fn engineering_normal_values_unchanged() { + assert_eq!(format_engineering(30.5, 2), "2'-6.50\""); + assert_eq!(format_engineering(36.0, 2), "3'-0.00\""); + assert_eq!(format_engineering(0.0, 2), "0'-0.00\""); + assert_eq!(format_engineering(-30.25, 2), "-2'-6.25\""); + } + + // A magnitude that rounds away to zero must not keep a lone minus sign. + #[test] + fn engineering_negative_zero_has_no_sign() { + assert_eq!(format_engineering(-0.001, 2), "0'-0.00\""); + } } From 6c6b394e569d441438b9f2a58c0f38efeddf9f4e Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Thu, 3 Sep 2026 15:13:57 +0700 Subject: [PATCH 2/3] fix(dimension): honour per-object text and extension-line colour overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Properties panel reads and writes ACAD_DSTYLE overrides for all three dimension colours (DIMCLRD 176, DIMCLRE 177, DIMCLRT 178), but the renderer consulted the override only for DIMCLRD and took DIMCLRE/DIMCLRT straight from the dimension style. Editing 'Text color' on a dimension therefore showed the new colour in the panel while the drawing kept rendering the inherited style colour — which for the common DIMCLRT = ByLayer default is the layer colour. (#898) Route all three through one helper so the renderer and the panel read the same value. --- src/entities/dimension.rs | 111 ++++++++++++++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index c27fc87db..5110e73f7 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3803,28 +3803,45 @@ fn tessellate_dimension_inner( ); // Per-spec colours: DIMCLRD (dim/arrows), DIMCLRE (ext), DIMCLRT (text). - // 0=ByBlock and 256=ByLayer fall through to entity_color. DIMCLRD also - // honours a per-object ACAD_DSTYLE override (code 176) so an edited - // dim-line colour renders even without touching the style. + // 0=ByBlock and 256=ByLayer fall through to entity_color. Each honours the + // per-object ACAD_DSTYLE override (codes 176/177/178) the Properties panel + // reads and writes, so an edited colour renders without touching the style. + let xd = &dim.base().common.extended_data; let dim_color = if selected { WireModel::SELECTED } else { - let dim_clr = crate::entities::dim_override::int( - &dim.base().common.extended_data, - crate::entities::dim_override::DIMCLRD, + resolve_dim_color( + dim_color_index( + xd, + crate::entities::dim_override::DIMCLRD, + style.map(|s| s.dimclrd).unwrap_or(0), + ), + entity_color, ) - .unwrap_or_else(|| style.map(|s| s.dimclrd).unwrap_or(0)); - resolve_dim_color(dim_clr, entity_color) }; let ext_color = if selected { WireModel::SELECTED } else { - resolve_dim_color(style.map(|s| s.dimclre).unwrap_or(0), entity_color) + resolve_dim_color( + dim_color_index( + xd, + crate::entities::dim_override::DIMCLRE, + style.map(|s| s.dimclre).unwrap_or(0), + ), + entity_color, + ) }; let text_color = if selected { entity_color // text wire color set by inner tessellate; keep entity tint } else { - resolve_dim_color(style.map(|s| s.dimclrt).unwrap_or(0), entity_color) + resolve_dim_color( + dim_color_index( + xd, + crate::entities::dim_override::DIMCLRT, + style.map(|s| s.dimclrt).unwrap_or(0), + ), + entity_color, + ) }; let snap_pts = dimension_snap_pts(dim); @@ -4194,6 +4211,18 @@ fn tessellate_dimension_inner( wires } +/// The ACI index a dimension colour renders with: the per-object ACAD_DSTYLE +/// override when the entity carries one, else the dimension style's value. +/// The Properties panel reads and writes these same overrides, so the renderer +/// has to consult them or an edited colour shows in the panel and nowhere else. +fn dim_color_index( + xd: &acadrust::xdata::ExtendedData, + code: i16, + inherited: i16, +) -> i16 { + crate::entities::dim_override::int(xd, code).unwrap_or(inherited) +} + fn resolve_dim_color(idx: i16, fallback: [f32; 4]) -> [f32; 4] { // DIMCLR* convention: 0 = BYBLOCK, 256 = BYLAYER → entity colour wins. if idx == 0 || idx == 256 { @@ -7270,6 +7299,68 @@ mod dimtad_tests { } } +#[cfg(test)] +mod dim_color_override_tests { + use super::dim_color_index; + use crate::entities::dim_override::{DIMCLRD, DIMCLRE, DIMCLRT}; + use acadrust::xdata::{ExtendedData, ExtendedDataRecord, XDataValue}; + + // An ACAD/DSTYLE record holding the overrides the Properties panel writes. + fn xdata(overrides: &[(i16, i16)]) -> ExtendedData { + let mut values = vec![ + XDataValue::String("DSTYLE".to_string()), + XDataValue::ControlString("{".to_string()), + ]; + for (code, aci) in overrides { + values.push(XDataValue::Integer16(*code)); + values.push(XDataValue::Integer16(*aci)); + } + values.push(XDataValue::ControlString("}".to_string())); + let mut record = ExtendedDataRecord::new("ACAD"); + for value in values { + record.add_value(value); + } + let mut xd = ExtendedData::new(); + xd.add_record(record); + xd + } + + // Every dim colour honours its per-object override, not just DIMCLRD. + // Text and extension-line colours edited on the Properties panel used to + // show there and render with the style/layer colour anyway. (#898) + #[test] + fn override_wins_over_style_for_every_dim_colour() { + let xd = xdata(&[(DIMCLRD, 1), (DIMCLRE, 3), (DIMCLRT, 5)]); + assert_eq!(dim_color_index(&xd, DIMCLRD, 256), 1); + assert_eq!(dim_color_index(&xd, DIMCLRE, 256), 3); + assert_eq!(dim_color_index(&xd, DIMCLRT, 256), 5); + } + + // Only the overridden variable changes; the rest still inherit the style. + #[test] + fn absent_override_falls_back_to_the_style_value() { + let xd = xdata(&[(DIMCLRT, 5)]); + assert_eq!(dim_color_index(&xd, DIMCLRT, 256), 5); + assert_eq!(dim_color_index(&xd, DIMCLRD, 7), 7); + assert_eq!(dim_color_index(&xd, DIMCLRE, 256), 256); + } + + // An entity with no override record at all is unaffected. + #[test] + fn no_record_uses_the_style_value() { + let xd = ExtendedData::new(); + assert_eq!(dim_color_index(&xd, DIMCLRT, 2), 2); + } + + // ByLayer (256) and ByBlock (0) stay meaningful as override values, so an + // explicit "back to ByLayer" edit is not mistaken for "no override". + #[test] + fn bylayer_override_is_kept() { + let xd = xdata(&[(DIMCLRT, 256)]); + assert_eq!(dim_color_index(&xd, DIMCLRT, 5), 256); + } +} + #[cfg(test)] mod arch_format_tests { use super::{format_architectural, format_fractional}; From 9db030eb1f05827ae1380e8073fd5567774446a1 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Thu, 3 Sep 2026 15:32:56 +0700 Subject: [PATCH 3/3] fix(expr-eval): make ^ right-associative parse_power accepted a single exponent (`unary ('^' unary)?`), so a chained power stopped after the first `^`. The remaining `^2` was left unconsumed, and eval_to_string's embedded-expression scan then handed it back as literal text: typing `2^3^2` into a coordinate or distance field produced the string "8^2" rather than 512, and `2^3^2+1" produced "8^3". Recurse into parse_power for the exponent so `2^3^2` reads as `2^(3^2)", the standard right-associative meaning, and the whole expression is consumed. --- src/app/expr_eval.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/app/expr_eval.rs b/src/app/expr_eval.rs index 7e6163d52..be064efcd 100644 --- a/src/app/expr_eval.rs +++ b/src/app/expr_eval.rs @@ -207,7 +207,13 @@ impl Parser { Ok(left) } - // power → unary ('^' unary)? + // power → unary ('^' power)? + // + // The exponent recurses into `parse_power`, not `parse_unary`, so `^` is + // right-associative — `2^3^2` is `2^(3^2)`, the standard reading. Binding + // it to the left both gave the wrong number and left the trailing `^2` + // unconsumed, which the caller's scan then handed back as literal text: + // `2^3^2` evaluated to the string "8^2" instead of 512. fn parse_power(&mut self, depth: usize) -> Result { if depth > MAX_RECURSION { return Err(()); @@ -215,7 +221,7 @@ impl Parser { let base = self.parse_unary(depth + 1)?; if let Some(Token::Caret) = self.peek() { self.advance(); - let exp = self.parse_unary(depth + 1)?; + let exp = self.parse_power(depth + 1)?; return Ok(base.powf(exp)); } Ok(base) @@ -591,6 +597,21 @@ mod tests { assert_eq!(eval_to_string("(((10)))"), "10"); } + // `^` is right-associative, so a chained exponent evaluates as one number + // instead of stopping at the first `^` and leaving the rest as text. + #[test] + fn power_is_right_associative() { + assert_eq!(eval_to_string("2^3^2"), "512"); + assert_eq!(eval_to_string("2^3^2+1"), "513"); + assert_eq!(eval_to_string("4^0.5^2"), "1.4142135623730951"); // 4^(1/4) + assert_eq!(eval_to_string("2^2^2^2"), "65536"); + // A single exponent, and a parenthesised one, are unaffected. + assert_eq!(eval_to_string("2^3"), "8"); + assert_eq!(eval_to_string("2^(3^2)"), "512"); + assert_eq!(eval_to_string("(2^3)^2"), "64"); + assert_eq!(eval_to_string("2^-1"), "0.5"); + } + #[test] fn unary_ops() { assert_eq!(eval_to_string("--5"), "5");