diff --git a/crates/io/tests/it/dim_block_text.rs b/crates/io/tests/it/dim_block_text.rs index f8a60c73..0c5fa0ca 100644 --- a/crates/io/tests/it/dim_block_text.rs +++ b/crates/io/tests/it/dim_block_text.rs @@ -1,8 +1,8 @@ //! The anonymous `*D` block written for a dimension (what other programs draw) holds its text at //! the height it is drawn at: the dimension's overrides and DIMSCALE (0 = the drawing's) count -//! (issue #367). +//! (issue #367). Angular text also keeps its decimal separator in the stored block (#1646). -use cadcraft_doc::{Common, DimKind, Dimension, Drawing, EntityKind, Space}; +use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space}; use cadcraft_geom::Vec3; /// The group 40 (height) of the first MTEXT in the BLOCKS section. @@ -51,3 +51,79 @@ fn dimension_block_text_has_the_drawn_height() { let zero = height_written(0.0, serde_json::json!({})); assert!(near(zero, 0.18), "DIMSCALE 0 in model space is scale 1, not ~0: {zero}"); } + +fn angular_drawing(separator_override: Option<&str>) -> Drawing { + let mut d = Drawing::new_metric(); + d.dim_styles.push(DimStyle { name: "Comma".into(), decimal_separator: ",".into(), angular_decimals: 1, ..DimStyle::default() }); + let angle = 39.4_f64.to_radians(); + let mut dm = Dimension { + kind: DimKind::Angular3P, + defpt: Vec3::new(50.0 * (angle / 2.0).cos(), 50.0 * (angle / 2.0).sin(), 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::new(100.0, 0.0, 0.0), + p14: Vec3::new(100.0 * angle.cos(), 100.0 * angle.sin(), 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Comma".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + if let Some(separator) = separator_override { + dm.overrides.insert("DIMDSEP".into(), serde_json::json!(separator)); + } + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + d +} + +#[test] +fn dxf_angular_block_text_keeps_the_decimal_separator() { + for (separator_override, expected) in [(None, "39,4°"), (Some("."), "39.4°")] { + let dxf = cadcraft_io::write_dxf(&angular_drawing(separator_override)); + let stored = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + // Inspect the saved block's MTEXT, without regenerating the dimension's text. + let texts: Vec<&str> = stored + .blocks + .values() + .filter(|b| b.name.starts_with("*D")) + .flat_map(|b| b.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::MText(t) => Some(t.contents.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, [expected], "DIMDSEP override: {separator_override:?}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_angular_block_text_keeps_the_decimal_separator() { + use std::io::Cursor; + + use acadrust::entities::EntityType; + use cadcraft_io::{SaveOptions, Version}; + + for (separator_override, expected) in [(None, "39,4°"), (Some("."), "39.4°")] { + let dwg = + cadcraft_io::write_with(&angular_drawing(separator_override), "angle.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }) + .unwrap(); + // DWG consumers use the stored *D geometry: read it directly, without CADCraft rendering it. + let stored = acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().unwrap(); + let texts: Vec<&str> = stored + .entities() + .filter_map(|e| match e { + EntityType::MText(t) if stored.block_records.iter().any(|b| b.handle == t.common.owner_handle && b.name.starts_with("*D")) => { + Some(t.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(texts, [expected], "DIMDSEP override: {separator_override:?}"); + } +} diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 3a885bfd..599fce73 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -10,7 +10,7 @@ use cadcraft_doc::{DimKind, DimStyle, Dimension}; use cadcraft_fonts::{MTextParams, TextFont}; use cadcraft_geom::{Arc, Vec2, ccw_sweep}; -use crate::units::{NumFormat, format_angle, format_decimal, format_linear}; +use crate::units::{NumFormat, format_angle_with_separator, format_decimal, format_linear}; /// Most DIMBREAK gaps drawn per dimension. const MAX_BREAKS: usize = 1000; @@ -887,7 +887,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font g.measurement = sweep; // DIMAZIN 1 / 2 suppress leading / trailing zeros (DIMZIN's bits 4 / 8). let azin = (st.angular_zero_suppression & 3) * 4; - let txt = format_angle(sweep, st.angular_unit, st.angular_decimals, azin); + let txt = format_angle_with_separator(sweep, st.angular_unit, st.angular_decimals, azin, &st.decimal_separator); // DIMPOST is a linear-dimension prefix/suffix; angles have their own format only. g.mtext = inspected(apply_override(&txt, d), d); let mut pts = Vec::new(); diff --git a/crates/render/src/units.rs b/crates/render/src/units.rs index f3d429c6..5f846e9b 100644 --- a/crates/render/src/units.rs +++ b/crates/render/src/units.rs @@ -188,8 +188,13 @@ pub fn format_linear(v: f64, f: &NumFormat) -> (String, String) { } } -/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC. +/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC, with a decimal point. pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String { + format_angle_with_separator(rad, unit, decimals, zin, ".") +} + +/// Format an angle with the dimension style's DIMDSEP separator. +pub(crate) fn format_angle_with_separator(rad: f64, unit: u8, decimals: u8, zin: u8, separator: &str) -> String { let rad = if rad.is_finite() { rad } else { 0.0 }; let deg = rad.to_degrees(); match unit { @@ -209,9 +214,9 @@ pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String { _ => format!("{d}°{m}'{s}\""), } } - 2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, ".")), - 3 => format!("{}r", format_decimal(rad, decimals, zin, ".")), - _ => format!("{}°", format_decimal(deg, decimals, zin, ".")), + 2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, separator)), + 3 => format!("{}r", format_decimal(rad, decimals, zin, separator)), + _ => format!("{}°", format_decimal(deg, decimals, zin, separator)), } } diff --git a/crates/render/tests/it/dim_text_format.rs b/crates/render/tests/it/dim_text_format.rs index 8501584f..e70deea0 100644 --- a/crates/render/tests/it/dim_text_format.rs +++ b/crates/render/tests/it/dim_text_format.rs @@ -72,3 +72,59 @@ fn dimpost_is_not_added_to_angular_dimensions() { a.text = "<> TYP".into(); assert_eq!(text(&a, serde_json::json!({"DIMPOST": "<>mm"})), "90° TYP"); } + +fn angular(radians: f64) -> Dimension { + let mut d = linear(0.0); + d.kind = DimKind::Angular3P; + d.p13 = Vec3::new(10.0, 0.0, 0.0); + d.p14 = Vec3::new(10.0 * radians.cos(), 10.0 * radians.sin(), 0.0); + d.defpt = Vec3::new(5.0 * (radians / 2.0).cos(), 5.0 * (radians / 2.0).sin(), 0.0); + d +} + +#[test] +fn angular_dimensions_use_the_style_decimal_separator() { + for (unit, radians, decimals, dot, comma) in + [(0, 39.4_f64.to_radians(), 1, "39.4°", "39,4°"), (2, std::f64::consts::FRAC_PI_4, 1, "50.0g", "50,0g"), (3, 1.25, 2, "1.25r", "1,25r")] + { + let three_point = angular(radians); + let mut two_line = three_point.clone(); + two_line.kind = DimKind::Angular; + two_line.p16 = three_point.defpt; + two_line.p15 = three_point.p14; + two_line.p14 = three_point.p13; + two_line.p13 = Vec3::ZERO; + two_line.defpt = Vec3::ZERO; + for d in [three_point, two_line] { + let vars = serde_json::json!({"DIMAUNIT": unit, "DIMADEC": decimals}); + assert_eq!(text(&d, vars.clone()), dot); + let mut vars = vars; + vars["DIMDSEP"] = serde_json::json!(","); + assert_eq!(text(&d, vars), comma); + } + } +} + +#[test] +fn angular_decimal_separator_respects_overrides_and_literal_text() { + let mut d = angular(39.4_f64.to_radians()); + d.text = "<> at 1.5".into(); + d.overrides.insert("DIMDSEP".into(), serde_json::json!(",")); + assert_eq!(text(&d, serde_json::json!({"DIMADEC": 1, "DIMDSEP": "."})), "39,4° at 1.5"); + d.overrides.insert("DIMDSEP".into(), serde_json::json!(".")); + assert_eq!(text(&d, serde_json::json!({"DIMADEC": 1, "DIMDSEP": ","})), "39.4° at 1.5"); +} + +#[test] +fn angular_zero_suppression_works_with_a_decimal_comma() { + let d = angular(0.5_f64.to_radians()); + for (zin, expected) in [(0, "0,500°"), (1, ",500°"), (2, "0,5°"), (3, ",5°")] { + assert_eq!(text(&d, serde_json::json!({"DIMADEC": 3, "DIMAZIN": zin, "DIMDSEP": ","})), expected); + } +} + +#[test] +fn angular_decimal_separator_keeps_dms_notation() { + let d = angular(45.5125_f64.to_radians()); + assert_eq!(text(&d, serde_json::json!({"DIMAUNIT": 1, "DIMADEC": 3, "DIMDSEP": ","})), "45°30'45\""); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 354a9589..1fced63c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -76,7 +76,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | | TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) | -| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | +| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | LEADER | yes | yes | | | MULTILEADER | yes (#459) | yes, native MULTILEADER with MLEADERSTYLE (#459) | several landings per multileader are folded into one; per-object overrides and annotative scale contexts not kept | | HATCH pattern / solid | yes | yes; associative hatches keep their boundary objects in blocks too, and the boundary carries the hatch reactor (stretched dynamic blocks drag the fill in AutoCAD) | | @@ -138,6 +138,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte |