From e76751604fe83f3c2eea784cdf37c10a75741fe1 Mon Sep 17 00:00:00 2001 From: ysdirector Date: Sun, 13 Sep 2026 18:10:02 +0800 Subject: [PATCH] fix(dimension): extend the DIMTMOVE=1 leader under the text for linear dims DIMTMOVE=1 ("moves the text and adds a leader") draws the leader from the midpoint of the dimension line to the text, so a text dragged outside the dimension line gets a diagonal that starts in the middle of the line and crosses the extension lines. Mechanical-drawing practice (GB/T 4458.4, and what AutoCAD itself draws) extends the dimension line along its own axis so that it runs underneath the text. Linear and aligned dimensions now do that whenever the text is outside the dimension-line extent; when the text is still inside it (only moved up/down) the previous connect-to-text leader is kept. Every other dimension type is unchanged, and only dimensions with DIMTMOVE=1 are affected at all (the shipped default is 0). Adds four unit tests covering the placements (outside right, outside left, inside, radius draws none) plus an integration render smoke test driven through the real Scene::entity_wires() -> export_pdf() pipeline, gated by OCS_DIM_LEADER_PDF so CI stays side-effect free. --- src/entities/dimension.rs | 181 ++++++++++++++++++++++++++----- tests/dim_leader_render_check.rs | 112 +++++++++++++++++++ 2 files changed, 264 insertions(+), 29 deletions(-) create mode 100644 tests/dim_leader_render_check.rs diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 667990bd2..dee900f1e 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3768,15 +3768,23 @@ fn tessellate_dimension_inner( // DIMTMOVE=1 connects the dimension line to rendered text. if let Some(s) = style { if s.dimtmove == 1 { - if let Some((anchor, txt)) = dimtmove_leader_endpoints(dim, text_layout.position) { + if let Some((anchor, txt, under_text)) = dimtmove_leader_endpoints( + dim, + text_layout.position, + text_layout.width * 0.5, + ) { let gap = dim_txt as f32 * 0.5; if (txt - anchor).length() > gap * 2.0 { - add_segment_with_text_break( - &mut geom.dim_lines, - anchor, - txt, - text_layout.break_box, - ); + if under_text { + add_segment(&mut geom.dim_lines, anchor, txt); + } else { + add_segment_with_text_break( + &mut geom.dim_lines, + anchor, + txt, + text_layout.break_box, + ); + } } } } @@ -4296,29 +4304,80 @@ fn split_ext_lines(points: &[[f32; 3]]) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { (first, rest) } +/// DIMTMOVE=1 leader for linear / aligned dimensions: +/// - Text outside the dimension-line extent along the axis → (near end, text far edge, true): +/// a segment along the dimension line itself passing underneath the text. Flagged true +/// so the text-break cut-out does not eat it. +/// - Text still inside → (point on the dim line facing the text, text position, false). +fn linear_leader( + first: Vec3, + second: Vec3, + def: Vec3, + axis: Vec3, + txt: Vec3, + text_half_width: f32, +) -> Option<(Vec3, Vec3, bool)> { + let perp = Vec3::new(-axis.y, axis.x, 0.0); + let dim_line_pos = def.dot(perp); + let a1 = first + perp * (dim_line_pos - first.dot(perp)); + let a2 = second + perp * (dim_line_pos - second.dot(perp)); + let t1 = a1.dot(axis); + let t2 = a2.dot(axis); + let tt = txt.dot(axis); + // Point on the dimension line at axis parameter t. + let at = |t: f32| a1 + axis * (t - t1); + let half = text_half_width.max(0.0); + if tt > t1.max(t2) { + // Text past the positive end: extend from that end to the text's far edge. + let (near, far) = if t2 >= t1 { (a2, t1) } else { (a1, t2) }; + let _ = far; + Some((near, at(tt + half), true)) + } else if tt < t1.min(t2) { + let (near, _) = if t1 <= t2 { (a1, t1) } else { (a2, t2) }; + Some((near, at(tt - half), true)) + } else { + Some((at(tt), txt, false)) + } +} + /// Endpoints for the DIMTMOVE=1 leader. -fn dimtmove_leader_endpoints(dim: &Dimension, txt: Vec3) -> Option<(Vec3, Vec3)> { +fn dimtmove_leader_endpoints( + dim: &Dimension, + txt: Vec3, + text_half_width: f32, +) -> Option<(Vec3, Vec3, bool)> { let lv = |v| vec3_local(v); - let anchor = match dim { + // Linear / aligned: when the text runs outside the dimension line along the axis, + // the line extends underneath the text (GB/T 4458.4 / AutoCAD DIMTMOVE=1); + // otherwise a leader connects the dim line to the text (which may sit above/below). + match dim { Dimension::Linear(d) => { - let perp = Vec3::new(-(d.rotation.sin() as f32), d.rotation.cos() as f32, 0.0); - let first = lv(d.first_point); - let second = lv(d.second_point); - let def = lv(d.definition_point); - let off1 = def.dot(perp) - first.dot(perp); - let off2 = def.dot(perp) - second.dot(perp); - (first + perp * off1 + second + perp * off2) * 0.5 + let axis = Vec3::new(d.rotation.cos() as f32, d.rotation.sin() as f32, 0.0); + return linear_leader( + lv(d.first_point), + lv(d.second_point), + lv(d.definition_point), + normalized_or(axis, Vec3::X), + txt, + text_half_width, + ); } Dimension::Aligned(d) => { let first = lv(d.first_point); let second = lv(d.second_point); let axis = normalized_or(second - first, Vec3::X); - let perp = Vec3::new(-axis.y, axis.x, 0.0); - let def = lv(d.definition_point); - let off1 = def.dot(perp) - first.dot(perp); - let off2 = def.dot(perp) - second.dot(perp); - (first + perp * off1 + second + perp * off2) * 0.5 + return linear_leader( + first, + second, + lv(d.definition_point), + axis, + txt, + text_half_width, + ); } + _ => {} + } + let anchor = match dim { Dimension::Radius(_) => return None, Dimension::Diameter(d) => { let chord = lv(d.angle_vertex); @@ -4353,7 +4412,7 @@ fn dimtmove_leader_endpoints(dim: &Dimension, txt: Vec3) -> Option<(Vec3, Vec3)> } _ => return None, }; - Some((anchor, txt)) + Some((anchor, txt, false)) } /// Build a rectangle of filled triangles sitting under the dim text, used @@ -7188,14 +7247,20 @@ pub(crate) fn baked_large_radial_geometry( } } if style.is_some_and(|style| style.dimtmove == 1) { - if let Some((anchor, endpoint)) = dimtmove_leader_endpoints(dimension, text.position) { + if let Some((anchor, endpoint, under_text)) = + dimtmove_leader_endpoints(dimension, text.position, text.width * 0.5) + { if anchor.distance(endpoint) > text_height as f32 { - add_segment_with_text_break( - &mut geometry.dim_lines, - anchor, - endpoint, - text.break_box, - ); + if under_text { + add_segment(&mut geometry.dim_lines, anchor, endpoint); + } else { + add_segment_with_text_break( + &mut geometry.dim_lines, + anchor, + endpoint, + text.break_box, + ); + } } } } @@ -7527,3 +7592,61 @@ mod arch_format_tests { assert_eq!(format_engineering(-0.001, 2), "0'-0.00\""); } } + +#[cfg(test)] +mod dimtmove_leader_tests { + use super::{dimtmove_leader_endpoints, vec3_local, Dimension}; + use acadrust::entities::DimensionLinear; + use acadrust::types::Vector3; + + fn v(x: f64, y: f64) -> Vector3 { + Vector3::new(x, y, 0.0) + } + + /// Text outside along the axis → the dim line extends underneath the text. + #[test] + fn text_outside_extends_dim_line_under_text() { + let mut d = DimensionLinear::horizontal(v(0.0, 0.0), v(20.0, 0.0)); + d.definition_point = v(0.0, -5.0); // dimension line at y=-5 + let dim = Dimension::Linear(d); + // text anchor outside on the right: centre x=30, half width 3 → far edge 33 + let txt = vec3_local(v(30.0, -3.0)); + let (anchor, end, under) = dimtmove_leader_endpoints(&dim, txt, 3.0).expect("leader"); + assert!(under, "outside text → leader runs underneath it (no text break)"); + assert!((anchor.x - 20.0).abs() < 1e-4 && (anchor.y + 5.0).abs() < 1e-4, "near end should be the dim line's right end (20,-5), got {anchor:?}"); + assert!((end.x - 33.0).abs() < 1e-4 && (end.y + 5.0).abs() < 1e-4, "far end should be the text's far edge (33,-5), got {end:?}"); + } + + /// Text outside on the left → extends leftwards. + #[test] + fn text_outside_left_extends_leftwards() { + let mut d = DimensionLinear::horizontal(v(0.0, 0.0), v(20.0, 0.0)); + d.definition_point = v(0.0, -5.0); + let dim = Dimension::Linear(d); + let txt = vec3_local(v(-12.0, -3.0)); + let (anchor, end, under) = dimtmove_leader_endpoints(&dim, txt, 2.0).expect("leader"); + assert!(under); + assert!((anchor.x - 0.0).abs() < 1e-4, "near end should be the dim line's left end, got {anchor:?}"); + assert!((end.x + 14.0).abs() < 1e-4, "far end should be the text's far edge at -14, got {end:?}"); + } + + /// Text still inside (only moved up/down) → leader from the dim line to the text. + #[test] + fn text_inside_connects_to_text() { + let mut d = DimensionLinear::horizontal(v(0.0, 0.0), v(20.0, 0.0)); + d.definition_point = v(0.0, -5.0); + let dim = Dimension::Linear(d); + let txt = vec3_local(v(10.0, 2.0)); + let (anchor, end, under) = dimtmove_leader_endpoints(&dim, txt, 3.0).expect("leader"); + assert!(!under, "inside → keep the connect-to-text leader (text break allowed)"); + assert!((anchor.x - 10.0).abs() < 1e-4 && (anchor.y + 5.0).abs() < 1e-4, "anchor should be the dim line point facing the text, got {anchor:?}"); + assert!((end.y - 2.0).abs() < 1e-4, "end should be the text position, got {end:?}"); + } + + /// Radius dimensions draw no leader (unchanged). + #[test] + fn radius_has_no_leader() { + let r = acadrust::entities::DimensionRadius::new(v(0.0, 0.0), v(0.0, 5.0)); + assert!(dimtmove_leader_endpoints(&Dimension::Radius(r), vec3_local(v(0.0, 8.0)), 2.0).is_none()); + } +} diff --git a/tests/dim_leader_render_check.rs b/tests/dim_leader_render_check.rs new file mode 100644 index 000000000..ee8839e2e --- /dev/null +++ b/tests/dim_leader_render_check.rs @@ -0,0 +1,112 @@ +//! Render smoke test: with DIMTMOVE=1 a linear dimension whose text is dragged +//! outside the dimension line draws a leader underneath the text +//! (GB/T 4458.4 / AutoCAD behaviour). +//! +//! Same pipeline as `pdf_export_text_check.rs` (Scene::entity_wires() → +//! export_pdf()) but writes a PDF for manual/visual inspection. The file is only +//! written when `OCS_DIM_LEADER_PDF` is set, so CI stays side-effect free. +use acadrust::entities::{Dimension, DimensionLinear}; +use acadrust::types::Vector3; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; +use acadrust::EntityType; +use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; +use OpenCADStudio::scene::Scene; + +/// Build a per-object DSTYLE override record (same shape as the properties +/// panel / `src/entities/dim_override.rs` write). +fn dstyle(pairs: &[(i16, XDataValue)]) -> ExtendedDataRecord { + let mut rec = ExtendedDataRecord::new("ACAD"); + rec.add_value(XDataValue::String("DSTYLE".into())); + rec.add_value(XDataValue::ControlString("{".into())); + for (code, value) in pairs { + rec.add_value(XDataValue::Integer16(*code)); + rec.add_value(value.clone()); + } + rec.add_value(XDataValue::ControlString("}".into())); + rec +} + +fn v(x: f64, y: f64) -> Vector3 { + Vector3::new(x, y, 0.0) +} + +/// Linear dimension with the text dragged outside to the right and DIMTMOVE=1: +/// the rendered dimension line must extend underneath the text. +#[test] +fn dim_leader_under_outside_text_renders() { + let out = std::env::var("OCS_DIM_LEADER_PDF").unwrap_or_default(); + + let mut scene = Scene::new(); + let mut d = DimensionLinear::horizontal(v(20.0, 100.0), v(80.0, 100.0)); + d.definition_point = v(20.0, 86.0); // dimension line at y=86 + d.base.definition_point = d.definition_point; + d.base.actual_measurement = 60.0; + // Text dragged past the right end of the dimension line (centre x=98, end x=80). + d.base.text_middle_point = v(98.0, 89.0); + d.base.insertion_point = d.base.text_middle_point; + d.base.text_user_positioned = true; + // Per-object overrides, as the properties panel writes them: DIMTMOVE=1 + // plus a visible text height / arrow size / gap. + d.base.style_name = "Standard".into(); + d.base.common.extended_data.add_record(dstyle(&[ + (279, XDataValue::Integer16(1)), // DIMTMOVE=1 → draw a leader when the text moves out + (140, XDataValue::Real(3.5)), // DIMTXT + (41, XDataValue::Real(3.5)), // DIMASZ + (147, XDataValue::Real(1.0)), // DIMGAP + (271, XDataValue::Integer16(2)), // DIMDEC + ])); + scene.add_entity(EntityType::Dimension(Dimension::Linear(d))); + + // Control case: the same dimension with DIMTMOVE=0 (no leader). + let mut d0 = DimensionLinear::horizontal(v(20.0, 60.0), v(80.0, 60.0)); + d0.definition_point = v(20.0, 46.0); + d0.base.definition_point = d0.definition_point; + d0.base.actual_measurement = 60.0; + d0.base.text_middle_point = v(98.0, 49.0); + d0.base.insertion_point = d0.base.text_middle_point; + d0.base.text_user_positioned = true; + d0.base.style_name = "Standard".into(); + d0.base.common.extended_data.add_record(dstyle(&[ + (279, XDataValue::Integer16(0)), // control: no leader + (140, XDataValue::Real(3.5)), + (41, XDataValue::Real(3.5)), + (147, XDataValue::Real(1.0)), + (271, XDataValue::Integer16(2)), + ])); + scene.add_entity(EntityType::Dimension(Dimension::Linear(d0))); + + let plot_wires: Vec = scene + .entity_wires() + .iter() + .map(|w| PlotWire { + wire: w.clone(), + draw_depth: 0.0, + }) + .collect(); + assert!(!plot_wires.is_empty(), "scene should produce geometry"); + + if out.trim().is_empty() { + return; // env var unset: exercise the render path only, write nothing + } + let path = std::path::PathBuf::from(&out); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + export_pdf( + &plot_wires, + &[], + &[], + 210.0, + 297.0, + 0.0, + 0.0, + 0, + 1.0, + None, + &path, + None, + PdfPlotOptions::default(), + ) + .expect("export pdf"); + println!("dim leader render smoke pdf written: {out}"); +}