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}"); +}