mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 19:36:49 -06:00
* AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours What AutoCAD takes from CADCraft's DWG/DXF as it is: - Justified TEXT/ATTRIB/ATTDEF (#1050): group 10 is now where the baseline starts, from the alignment point and the text's width, not the alignment point itself; AutoCAD drew centred and right-aligned text from DWG starting at its alignment point. A stored point close to CADCraft's own (AutoCAD's, exact for its fonts) is kept. - DIMPOST (#1053) is written even when empty: the DWG bridge defaulted a missing one to "<>", and AutoCAD then dropped the "R" of radius dimensions when it regenerated them. - The current dimension style (#1051): the DXF header now carries its DIM* variables (AutoCAD draws new and updated dimensions from them); the DWG bridge points $DIMSTYLE at the style by name (its handle was renumbered, so the writer fell back to Standard), and reading a DWG gives the current dimension and text styles their names back. - *D blocks (#1052): lines, arrows, extension lines and text take the style's DIMCLRD/DIMCLRE/DIMCLRT instead of ByBlock. #1054 (group 42 of a rotated linear dimension) was already fixed on main by #855; acad_interop.rs keeps it covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test: angular dimensions store their angle in radians (group 42) Three-point (60°, 174°) and two-line (120°) angular dimensions, read back from the DWG as DXF. Already right on main since #855; this guards it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Justified text made here: Left Middle and Left Bottom write their baseline start too text_insert kept a stored insertion point that was within 0.1 x an estimated width + 0.5 x height of CADCraft's baseline start, taking it for AutoCAD's own. The width was estimated from the alignment point's offset, which for Left Middle is only h/2 (vertical), so a text made here, whose stored point is the alignment point, passed and kept it: AutoCAD drew it half a height high (Left Bottom: a third). Reported with a test by @lee-cyrille-maskell. A stored point equal to the alignment point is now always stale. Otherwise the offset is split along and across the baseline: along it the same slack as before (a tenth of the text's width plus half its height, as fonts and their substitutes differ most in width), across it a fifth of the height. Multiline attribute values keep the distance-only check, since their lines are placed by the embedded MTEXT. On 12 drawings made in AutoCAD (1,055 justified texts) the insertion points written are the same as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
13 KiB
Rust
297 lines
13 KiB
Rust
//! What AutoCAD reads from CADCraft's DWG and DXF as it is, without recomputing it: justified
|
|
//! text's insertion point (#1050), DIMPOST (#1053), the current dimension style and its header
|
|
//! variables (#1051), the colours of a dimension's `*D` block (#1052) and the stored measurement
|
|
//! of a rotated linear dimension (#1054) and of angular dimensions.
|
|
|
|
use cadcraft_color::Color;
|
|
use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, HAlign, Space, Text, VAlign};
|
|
use cadcraft_geom::Vec3;
|
|
use cadcraft_io::{SaveOptions, Version, read, write_dxf, write_with};
|
|
|
|
fn dwg_roundtrip(d: &Drawing) -> Drawing {
|
|
let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
|
|
read(&dwg, "case.dwg").unwrap()
|
|
}
|
|
|
|
/// `(code, value)` pairs of a DXF, values trimmed.
|
|
fn tags(dxf: &str) -> Vec<(i32, String)> {
|
|
let lines: Vec<&str> = dxf.lines().collect();
|
|
lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim().to_string()))).collect()
|
|
}
|
|
|
|
fn text(value: &str, at: Vec3, h: HAlign, v: VAlign) -> EntityKind {
|
|
EntityKind::Text(Text {
|
|
insert: at,
|
|
align_pt: Some(at),
|
|
height: 5.0,
|
|
value: value.into(),
|
|
rotation: 0.0,
|
|
width_factor: 1.0,
|
|
oblique: 0.0,
|
|
style: "Standard".into(),
|
|
halign: h,
|
|
valign: v,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn justified_text_is_written_with_its_baseline_start_as_insertion_point() {
|
|
let mut d = Drawing::new_metric();
|
|
d.add(&Space::Model, Common::default(), text("MIDDLE CENTER", Vec3::new(100.0, 50.0, 0.0), HAlign::Center, VAlign::Middle)).unwrap();
|
|
d.add(&Space::Model, Common::default(), text("BOTTOM RIGHT", Vec3::new(100.0, 30.0, 0.0), HAlign::Right, VAlign::Bottom)).unwrap();
|
|
let width = |s: &str| cadcraft_fonts::line_width(s, 5.0, 1.0);
|
|
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
|
|
|
// DXF: group 10 is where the baseline starts, group 11 the alignment point.
|
|
let t = tags(&write_dxf(&d));
|
|
let texts: Vec<usize> = t.iter().enumerate().filter(|(_, (c, v))| *c == 0 && v == "TEXT").map(|(i, _)| i).collect();
|
|
let group = |start: usize, code: i32| -> f64 { t[start..].iter().find(|(c, _)| *c == code).unwrap().1.parse().unwrap() };
|
|
assert_eq!(texts.len(), 2);
|
|
assert!(near(group(texts[0], 10), 100.0 - width("MIDDLE CENTER") / 2.0), "{}", group(texts[0], 10));
|
|
assert!(near(group(texts[0], 11), 100.0));
|
|
assert!(near(group(texts[1], 10), 100.0 - width("BOTTOM RIGHT")), "{}", group(texts[1], 10));
|
|
|
|
// DWG: the same, through the bridge.
|
|
let back = dwg_roundtrip(&d);
|
|
let starts: Vec<(f64, f64)> = back
|
|
.model
|
|
.iter()
|
|
.filter_map(|e| match &e.kind {
|
|
EntityKind::Text(t) => Some((t.insert.x, t.align_pt.map_or(f64::NAN, |a| a.x))),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(starts.len(), 2);
|
|
assert!(near(starts[0].0, 100.0 - width("MIDDLE CENTER") / 2.0) && near(starts[0].1, 100.0), "{starts:?}");
|
|
assert!(near(starts[1].0, 100.0 - width("BOTTOM RIGHT")) && near(starts[1].1, 100.0), "{starts:?}");
|
|
}
|
|
|
|
/// A metric drawing whose current style is S25 (text 0.25, 2 decimals, DIMSCALE 25, red lines,
|
|
/// green text), with one dimension.
|
|
fn s25(kind: DimKind, p13: Vec3, p14: Vec3, defpt: Vec3) -> Drawing {
|
|
let mut d = Drawing::new_metric();
|
|
d.dim_styles.push(DimStyle {
|
|
name: "S25".into(),
|
|
scale: 25.0,
|
|
text_height: 0.25,
|
|
decimals: 2,
|
|
dim_line_color: Color::from_aci(1),
|
|
ext_line_color: Color::from_aci(1),
|
|
text_color: Color::from_aci(3),
|
|
..DimStyle::default()
|
|
});
|
|
d.header.set_str("DIMSTYLE", "S25");
|
|
let dm = Dimension {
|
|
kind,
|
|
defpt,
|
|
text_mid: Vec3::ZERO,
|
|
p13,
|
|
p14,
|
|
p15: Vec3::ZERO,
|
|
p16: Vec3::ZERO,
|
|
text: String::new(),
|
|
style: "S25".into(),
|
|
measurement: 0.0,
|
|
text_rotation: 0.0,
|
|
user_text_pos: false,
|
|
block: None,
|
|
overrides: Default::default(),
|
|
assoc: Vec::new(),
|
|
marks: Default::default(),
|
|
};
|
|
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
|
|
d
|
|
}
|
|
|
|
fn linear(d_rotation: f64) -> Drawing {
|
|
s25(DimKind::Linear { rotation: d_rotation }, Vec3::ZERO, Vec3::new(100.0, 0.0, 0.0), Vec3::new(50.0, -20.0, 0.0))
|
|
}
|
|
|
|
#[test]
|
|
fn the_current_dimension_style_and_its_variables_reach_autocad() {
|
|
let d = linear(0.0);
|
|
let t = tags(&write_dxf(&d));
|
|
let var = |name: &str| t.windows(2).find(|w| w[0] == (9, name.to_string())).map(|w| w[1].1.clone());
|
|
assert_eq!(var("$DIMSTYLE").as_deref(), Some("S25"));
|
|
assert_eq!(var("$DIMTXT").and_then(|v| v.parse::<f64>().ok()), Some(0.25));
|
|
assert_eq!(var("$DIMDEC").as_deref(), Some("2"));
|
|
assert_eq!(var("$DIMCLRT").as_deref(), Some("3"));
|
|
// DIMPOST is written even when empty (#1053).
|
|
let back = dwg_roundtrip(&d);
|
|
assert_eq!(back.header.str("DIMSTYLE", ""), "S25", "the DWG keeps the current style");
|
|
for s in &back.dim_styles {
|
|
assert_eq!(s.post, "", "{}: DIMPOST stays empty, not \"<>\"", s.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_dimension_block_takes_the_style_colours() {
|
|
let d = linear(0.0);
|
|
let t = tags(&write_dxf(&d));
|
|
let blocks = t.iter().position(|(c, v)| *c == 2 && v == "BLOCKS").unwrap();
|
|
let start = blocks + t[blocks..].iter().position(|(c, v)| *c == 2 && v.starts_with("*D")).unwrap();
|
|
let end = start + t[start..].iter().position(|(c, v)| *c == 0 && v == "ENDBLK").unwrap();
|
|
let mut colors: Vec<(String, i64)> = Vec::new();
|
|
let mut kind = String::new();
|
|
for (c, v) in &t[start..end] {
|
|
match c {
|
|
0 => kind = v.clone(),
|
|
62 => colors.push((kind.clone(), v.parse().unwrap())),
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(colors.iter().any(|(k, c)| k == "LINE" && *c == 1), "{colors:?}");
|
|
assert!(colors.iter().any(|(k, c)| k == "MTEXT" && *c == 3), "{colors:?}");
|
|
assert!(!colors.iter().any(|(_, c)| *c == 0), "nothing ByBlock: {colors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rotated_linear_dimension_stores_its_projected_length() {
|
|
let d = s25(
|
|
DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 },
|
|
Vec3::new(-5.0, 0.0, 0.0),
|
|
Vec3::new(0.0, 210.0, 0.0),
|
|
Vec3::new(-40.0, 105.0, 0.0),
|
|
);
|
|
let dwg = write_with(&d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
|
|
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
|
let t = tags(&dxf);
|
|
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
|
|
let m: f64 = t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap();
|
|
assert!((m - 210.0).abs() < 1e-9, "group 42 = {m}");
|
|
}
|
|
|
|
/// Group 10 and the value of every TEXT in a DXF, in file order.
|
|
fn text_starts(dxf: &str) -> Vec<(String, cadcraft_geom::Vec2)> {
|
|
let t = tags(dxf);
|
|
let mut out = Vec::new();
|
|
for (i, (c, v)) in t.iter().enumerate() {
|
|
if *c == 0 && v == "TEXT" {
|
|
let end = t[i + 1..].iter().position(|(c, _)| *c == 0).map_or(t.len(), |k| i + 1 + k);
|
|
let g = |code: i32| t[i + 1..end].iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone());
|
|
let num = |code: i32| g(code).and_then(|v| v.parse::<f64>().ok()).unwrap_or(f64::NAN);
|
|
out.push((g(1).unwrap_or_default(), cadcraft_geom::Vec2::new(num(10), num(20))));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Justified TEXT made in CADCraft (insertion point = alignment point) is written with its
|
|
/// baseline start in group 10, for every justification and rotated too: placed baseline-left at
|
|
/// the written group 10, each text covers what CADCraft draws for the justified one. Left Middle
|
|
/// and Left Bottom were kept at the alignment point, half or a third of a height off, because
|
|
/// that point passed for AutoCAD's own (test by @lee-cyrille-maskell, from their fix for #1050).
|
|
#[test]
|
|
fn justified_text_made_here_writes_its_baseline_start_for_every_justification() {
|
|
use HAlign::{Center, Left, Right};
|
|
use VAlign::{Baseline, Bottom, Top};
|
|
let just = [
|
|
(Left, Baseline),
|
|
(Center, Baseline),
|
|
(Right, Baseline),
|
|
(HAlign::Middle, Baseline),
|
|
(Left, Top),
|
|
(Center, Top),
|
|
(Right, Top),
|
|
(Left, VAlign::Middle),
|
|
(Center, VAlign::Middle),
|
|
(Right, VAlign::Middle),
|
|
(Left, Bottom),
|
|
(Center, Bottom),
|
|
(Right, Bottom),
|
|
];
|
|
let mut d = Drawing::new_imperial();
|
|
let mut made = Vec::new();
|
|
for (i, &(h, v)) in just.iter().enumerate() {
|
|
for rotation in [0.0, 0.5] {
|
|
let at = Vec3::new(100.0, 400.0 - 22.0 * i as f64, 0.0);
|
|
let left = h == Left && v == Baseline;
|
|
let t = Text {
|
|
insert: at,
|
|
align_pt: if left { None } else { Some(at) },
|
|
height: 5.0,
|
|
value: format!("{h:?} {v:?} {rotation} Hxg"),
|
|
rotation,
|
|
width_factor: 0.8,
|
|
oblique: 0.0,
|
|
style: "Standard".into(),
|
|
halign: h,
|
|
valign: v,
|
|
};
|
|
d.add(&Space::Model, Common::default(), EntityKind::Text(t.clone())).unwrap();
|
|
made.push(t);
|
|
}
|
|
}
|
|
let dxf = write_dxf(&d);
|
|
let starts = text_starts(&dxf);
|
|
assert_eq!(starts.len(), made.len());
|
|
let near = |a: cadcraft_geom::Vec2, b: cadcraft_geom::Vec2| (a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6;
|
|
for (t, (value, p)) in made.iter().zip(&starts) {
|
|
assert_eq!(&t.value, value);
|
|
let (_, want) = cadcraft_render::place_text_entity(&d, t, &t.value);
|
|
let start = Text { insert: Vec3::new(p.x, p.y, 0.0), align_pt: None, halign: Left, valign: Baseline, ..t.clone() };
|
|
let (_, got) = cadcraft_render::place_text_entity(&d, &start, &start.value);
|
|
assert!(near(got.min, want.min) && near(got.max, want.max), "{value}: drawn {want:?}, from group 10 {got:?}");
|
|
}
|
|
// Read back and saved again, group 10 stays put.
|
|
let back = read(dxf.as_bytes(), "t.dxf").unwrap();
|
|
assert_eq!(text_starts(&write_dxf(&back)), starts);
|
|
}
|
|
|
|
/// Group 42 of a dimension in the DXF AutoCAD reads from `d`'s DWG.
|
|
fn stored_measurement(d: &Drawing) -> f64 {
|
|
let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
|
|
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
|
|
let t = tags(&dxf);
|
|
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
|
|
t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap()
|
|
}
|
|
|
|
fn polar(deg: f64, r: f64) -> Vec3 {
|
|
let a = deg.to_radians();
|
|
Vec3::new(r * a.cos(), r * a.sin(), 0.0)
|
|
}
|
|
|
|
/// Angular dimensions store their angle too, in radians as AutoCAD does (0.0 in 0.6.2).
|
|
#[test]
|
|
fn an_angular_dimension_stores_its_angle_in_radians() {
|
|
// Three points: vertex (p15) at the origin, legs through p13 and p14, the arc through defpt.
|
|
for deg in [60.0, 174.0] {
|
|
let d = s25(DimKind::Angular3P, polar(0.0, 20.0), polar(deg, 20.0), polar(deg / 2.0, 15.0));
|
|
let m = stored_measurement(&d);
|
|
assert!((m - f64::to_radians(deg)).abs() < 1e-9, "{deg}°: group 42 = {m}");
|
|
}
|
|
// Two lines, 120° apart: p13-p14 and defpt-p15, the arc through p16.
|
|
let mut d = s25(DimKind::Angular, polar(0.0, 5.0), polar(0.0, 20.0), polar(120.0, 5.0));
|
|
let h = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).unwrap().handle;
|
|
d.modify_entity(h, |e| {
|
|
if let EntityKind::Dimension(dm) = &mut e.kind {
|
|
dm.p15 = polar(120.0, 20.0);
|
|
dm.p16 = polar(60.0, 15.0);
|
|
}
|
|
})
|
|
.unwrap();
|
|
let m = stored_measurement(&d);
|
|
assert!((m - f64::to_radians(120.0)).abs() < 1e-9, "two lines: group 42 = {m}");
|
|
}
|
|
|
|
/// A justified text read from AutoCAD keeps AutoCAD's insertion point (exact for its own fonts)
|
|
/// when it is near CADCraft's; only a stale one (the alignment point, or a text that has since
|
|
/// changed length) is replaced.
|
|
#[test]
|
|
fn an_insertion_point_from_autocad_is_kept() {
|
|
let mut d = Drawing::new_metric();
|
|
let at = Vec3::new(100.0, 50.0, 0.0);
|
|
let ours = 100.0 - cadcraft_fonts::line_width("MIDDLE CENTER", 5.0, 1.0) / 2.0;
|
|
let mut k = text("MIDDLE CENTER", at, HAlign::Center, VAlign::Middle);
|
|
if let EntityKind::Text(t) = &mut k {
|
|
// AutoCAD's font is a little narrower than CADCraft's.
|
|
t.insert = Vec3::new(ours + 2.5, 47.5, 0.0);
|
|
}
|
|
d.add(&Space::Model, Common::default(), k).unwrap();
|
|
let t = tags(&write_dxf(&d));
|
|
let i = t.iter().position(|(c, v)| *c == 0 && v == "TEXT").unwrap();
|
|
let x: f64 = t[i..].iter().find(|(c, _)| *c == 10).unwrap().1.parse().unwrap();
|
|
assert!((x - (ours + 2.5)).abs() < 1e-9, "kept: {x}");
|
|
}
|