TOLERANCE: GD&T feature control frames (fixes #420) (#427)

Squash of PR #427 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:19:32 -04:00
1 parent 139d98bf6f
commit 9e3379882f
22 files changed
+1297 -3

No files matched your search

+11
View File
@@ -705,6 +705,7 @@ pub enum EntityKind {
Image(Image),
Wipeout(Wipeout),
Table(Table),
Tolerance(crate::Tolerance),
Unknown(Unknown),
}
@@ -755,6 +756,7 @@ impl EntityKind {
EntityKind::Image(_) => "Raster Image",
EntityKind::Wipeout(_) => "Wipeout",
EntityKind::Table(_) => "Table",
EntityKind::Tolerance(_) => "Tolerance",
EntityKind::Unknown(_) => "Proxy",
}
}
@@ -786,6 +788,7 @@ impl EntityKind {
EntityKind::Image(_) => "IMAGE",
EntityKind::Wipeout(_) => "WIPEOUT",
EntityKind::Table(_) => "ACAD_TABLE",
EntityKind::Tolerance(_) => "TOLERANCE",
EntityKind::Unknown(u) => u.dxf_type.as_str(),
}
}
@@ -859,6 +862,7 @@ impl EntityKind {
.collect(),
EntityKind::Image(i) => vec![Prim::Point(i.insert.xy())],
EntityKind::Table(t) => vec![Prim::Point(t.insert.xy())],
EntityKind::Tolerance(t) => vec![Prim::Point(t.insert.xy())],
EntityKind::Unknown(_) => Vec::new(),
}
}
@@ -1018,6 +1022,12 @@ impl EntityKind {
t.row_heights.iter_mut().for_each(|h| *h *= s);
t.text_height *= s;
}
EntityKind::Tolerance(t) => {
t3(&mut t.insert);
// Like text with MIRRTEXT = 0: a mirrored frame keeps reading left to right.
let a = if mirror { t.rotation() } else { t.rotation() + rot };
t.dir = Vec2::from_angle(cadcraft_geom::norm_angle(a)).to3(0.0);
}
EntityKind::Unknown(_) => {}
}
}
@@ -1082,6 +1092,7 @@ impl EntityKind {
EntityKind::Image(i) => vec![i.insert.xy()],
EntityKind::Wipeout(w) => w.boundary.clone(),
EntityKind::Table(t) => vec![t.insert.xy()],
EntityKind::Tolerance(t) => vec![t.insert.xy()],
EntityKind::Unknown(_) => Vec::new(),
}
}
+2
View File
@@ -162,6 +162,8 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
let h: f64 = t.row_heights.iter().sum();
Bounds2::new(t.insert.xy(), t.insert.xy() + Vec2::new(w, -h))
}
// Text widths estimated like `text_box`; the renderer measures the font.
EntityKind::Tolerance(t) => t.layout(d, &|s, h| h * 0.9 * s.chars().count() as f64).bounds,
EntityKind::Image(i) => {
let o = i.insert.xy();
let u = i.u.xy() * i.size.x;
+626
View File
@@ -0,0 +1,626 @@
//! Geometric tolerancing (GD&T): the TOLERANCE entity (feature control frames) and its layout.
//!
//! The frame is stored as the DXF Reference's "visual representation string" (TOLERANCE group 1):
//! frame lines separated by `^J`, compartments separated by `%%v`, and the geometric symbols as
//! characters of the `gdt` font inside `{\Fgdt;…}` runs. CADCraft never uses that font: every
//! symbol is drawn by [`symbol_prims`] from our own geometry (standard ISO/ASME symbol shapes).
use cadcraft_geom::{Bounds2, Vec2, Vec3};
use serde::{Deserialize, Serialize};
/// Caps for hostile frame strings (files, command parameters).
pub const MAX_FRAME_CHARS: usize = 4096;
pub const MAX_FRAME_ROWS: usize = 8;
pub const MAX_FRAME_CELLS: usize = 12;
/// A TOLERANCE entity: a feature control frame.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tolerance {
/// Insertion point: the middle of the left edge of the first frame line.
pub insert: Vec3,
/// The frame's X-axis direction (DXF group 11).
#[serde(default = "x_axis")]
pub dir: Vec3,
/// Dimension style: text height (DIMTXT × DIMSCALE), gap, colours, text style.
#[serde(default = "standard")]
pub style: String,
/// The frame contents (DXF group 1).
pub text: String,
}
fn x_axis() -> Vec3 {
Vec3::new(1.0, 0.0, 0.0)
}
fn standard() -> String {
"Standard".into()
}
impl Tolerance {
/// The frame's rotation (radians from +X); 0 when the direction is unusable.
pub fn rotation(&self) -> f64 {
let d = self.dir.xy();
if d.is_finite() && d.len() > 1e-12 { d.angle() } else { 0.0 }
}
/// Text height and padding from the dimension style: DIMTXT and DIMGAP times the style's
/// DIMSCALE (the drawing's when 0), or the text style's fixed height.
pub fn text_size(&self, d: &crate::Drawing) -> (f64, f64) {
let st = d.dim_style(&self.style).cloned().unwrap_or_default();
let k = st.effective_scale(d.header.f64("DIMSCALE", 1.0));
let fixed = d.text_style(&st.text_style).map(|t| t.height).filter(|h| h.is_finite() && *h > 0.0);
let h = fixed.unwrap_or(st.text_height * k);
(if h.is_finite() && h > 0.0 { h } else { 0.18 }, st.text_gap * k)
}
/// The frame laid out with `measure(text, height)` as the width of plain text.
pub fn layout(&self, d: &crate::Drawing, measure: &dyn Fn(&str, f64) -> f64) -> GdtLayout {
let (h, gap) = self.text_size(d);
layout_frame(&self.text, self.insert.xy(), self.rotation(), h, gap, measure)
}
}
/// A geometric characteristic, material condition or zone symbol.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GdtSymbol {
Position,
Concentricity,
Symmetry,
Parallelism,
Perpendicularity,
Angularity,
Cylindricity,
Flatness,
Circularity,
Straightness,
SurfaceProfile,
LineProfile,
CircularRunout,
TotalRunout,
Diameter,
/// Maximum material condition (circled M).
Mmc,
/// Least material condition (circled L).
Lmc,
/// Regardless of feature size (circled S).
Rfs,
/// Projected tolerance zone (circled P).
Projected,
}
/// Every symbol with its `gdt` font character and its name.
const SYMBOLS: [(GdtSymbol, char, &str); 19] = [
(GdtSymbol::Position, 'j', "position"),
(GdtSymbol::Concentricity, 'r', "concentricity"),
(GdtSymbol::Symmetry, 'i', "symmetry"),
(GdtSymbol::Parallelism, 'f', "parallelism"),
(GdtSymbol::Perpendicularity, 'b', "perpendicularity"),
(GdtSymbol::Angularity, 'a', "angularity"),
(GdtSymbol::Cylindricity, 'g', "cylindricity"),
(GdtSymbol::Flatness, 'c', "flatness"),
(GdtSymbol::Circularity, 'e', "circularity"),
(GdtSymbol::Straightness, 'u', "straightness"),
(GdtSymbol::SurfaceProfile, 'd', "surfaceprofile"),
(GdtSymbol::LineProfile, 'k', "lineprofile"),
(GdtSymbol::CircularRunout, 'h', "circularrunout"),
(GdtSymbol::TotalRunout, 't', "totalrunout"),
(GdtSymbol::Diameter, 'n', "diameter"),
(GdtSymbol::Mmc, 'm', "mmc"),
(GdtSymbol::Lmc, 'l', "lmc"),
(GdtSymbol::Rfs, 's', "rfs"),
(GdtSymbol::Projected, 'p', "projected"),
];
impl GdtSymbol {
/// The symbol's character in the frame string's `{\Fgdt;…}` runs.
pub fn code(self) -> char {
SYMBOLS.iter().find(|(s, _, _)| *s == self).map(|(_, c, _)| *c).unwrap_or('j')
}
pub fn from_code(c: char) -> Option<GdtSymbol> {
SYMBOLS.iter().find(|(_, k, _)| *k == c).map(|(s, _, _)| *s)
}
/// Lower-case name ("position", "mmc", …).
pub fn name(self) -> &'static str {
SYMBOLS.iter().find(|(s, _, _)| *s == self).map(|(_, _, n)| *n).unwrap_or("position")
}
/// A symbol by name (case, spaces, dashes and underscores ignored) or by a few common
/// alternatives ("true position", "runout", "profile", "M", "L", "S").
pub fn parse(s: &str) -> Option<GdtSymbol> {
let k: String = s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect();
let alias = match k.as_str() {
"trueposition" | "pos" => Some(GdtSymbol::Position),
"coaxiality" => Some(GdtSymbol::Concentricity),
"roundness" => Some(GdtSymbol::Circularity),
"profile" | "surface" | "profileofasurface" => Some(GdtSymbol::SurfaceProfile),
"line" | "profileofaline" => Some(GdtSymbol::LineProfile),
"runout" => Some(GdtSymbol::CircularRunout),
"total" => Some(GdtSymbol::TotalRunout),
"m" => Some(GdtSymbol::Mmc),
"l" => Some(GdtSymbol::Lmc),
"s" => Some(GdtSymbol::Rfs),
"dia" | "ø" | "⌀" | "%%c" => Some(GdtSymbol::Diameter),
_ => None,
};
alias.or_else(|| SYMBOLS.iter().find(|(_, _, n)| *n == k).map(|(s, _, _)| *s))
}
/// A geometric characteristic (the first compartment of a frame line).
pub fn is_characteristic(self) -> bool {
!matches!(self, GdtSymbol::Diameter | GdtSymbol::Mmc | GdtSymbol::Lmc | GdtSymbol::Rfs | GdtSymbol::Projected)
}
/// A material condition modifier.
pub fn is_material(self) -> bool {
matches!(self, GdtSymbol::Mmc | GdtSymbol::Lmc | GdtSymbol::Rfs)
}
}
/// A piece of a compartment: plain text or a symbol.
#[derive(Clone, Debug, PartialEq)]
pub enum GdtRun {
Text(String),
Symbol(GdtSymbol),
}
/// One compartment's runs.
pub type Compartment = Vec<GdtRun>;
/// Parse a frame string into lines of compartments. Unknown formatting codes are skipped, an
/// unterminated code ends the string, and the sizes are capped ([`MAX_FRAME_CHARS`],
/// [`MAX_FRAME_ROWS`], [`MAX_FRAME_CELLS`]).
pub fn parse_frame(text: &str) -> Vec<Vec<Compartment>> {
let cs: Vec<char> = text.chars().take(MAX_FRAME_CHARS).collect();
let at = |i: usize| cs.get(i).copied();
let mut rows: Vec<Vec<Compartment>> = vec![vec![Vec::new()]];
// Font stack: whether the `gdt` font is current, per brace level.
let mut gdt = false;
let mut stack: Vec<bool> = Vec::new();
let push = |rows: &mut Vec<Vec<Compartment>>, run: GdtRun| {
let Some(cell) = rows.last_mut().and_then(|r| r.last_mut()) else { return };
match (cell.last_mut(), run) {
(Some(GdtRun::Text(t)), GdtRun::Text(s)) => t.push_str(&s),
(_, run) => cell.push(run),
}
};
let mut i = 0;
while let Some(c) = at(i) {
let next = at(i + 1);
let lower = |k: usize| at(k).map(|c| c.to_ascii_lowercase());
// Line break: `^J`, `\P` or a raw newline.
if (c == '^' && matches!(next, Some('J' | 'j'))) || (c == '\\' && next == Some('P')) || c == '\n' {
if rows.len() >= MAX_FRAME_ROWS {
break;
}
rows.push(vec![Vec::new()]);
i += if c == '\n' { 1 } else { 2 };
continue;
}
if c == '\r' {
i += 1;
continue;
}
if c == '%' && next == Some('%') {
match lower(i + 2) {
Some('v') => {
if let Some(r) = rows.last_mut()
&& r.len() < MAX_FRAME_CELLS
{
r.push(Vec::new());
}
}
Some('c') => push(&mut rows, GdtRun::Symbol(GdtSymbol::Diameter)),
Some('d') => push(&mut rows, GdtRun::Text("°".into())),
Some('p') => push(&mut rows, GdtRun::Text("±".into())),
Some('%') => push(&mut rows, GdtRun::Text("%".into())),
Some(o) => push(&mut rows, GdtRun::Text(format!("%%{o}"))),
None => break,
}
i += 3;
continue;
}
match c {
'{' => {
if stack.len() < 64 {
stack.push(gdt);
}
i += 1;
}
'}' => {
gdt = stack.pop().unwrap_or(false);
i += 1;
}
'\\' => match next {
Some('\\' | '{' | '}') => {
if let Some(n) = next {
push(&mut rows, GdtRun::Text(n.to_string()));
}
i += 2;
}
// Font switch `\Fname;` / `\fname|b0|i0;`: the gdt font draws symbols.
Some('F' | 'f') => {
let Some(end) = (i + 2..cs.len()).find(|k| at(*k) == Some(';')) else { break };
let name: String = cs.get(i + 2..end).unwrap_or(&[]).iter().take_while(|c| **c != '|').collect();
gdt = name.trim().to_ascii_lowercase().starts_with("gdt");
i = end + 1;
}
// Codes with a value up to `;` (height, colour, tracking, width, oblique, alignment…).
Some('A' | 'C' | 'c' | 'H' | 'Q' | 'T' | 'W' | 'S') => {
let Some(end) = (i + 2..cs.len()).find(|k| at(*k) == Some(';')) else { break };
i = end + 1;
}
// On/off toggles (underline, overline, strike-through) and the non-breaking space.
Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => i += 2,
Some('~') => {
push(&mut rows, GdtRun::Text(" ".into()));
i += 2;
}
Some(_) => {
push(&mut rows, GdtRun::Text("\\".into()));
i += 1;
}
None => break,
},
c if gdt => {
push(&mut rows, GdtSymbol::from_code(c).map(GdtRun::Symbol).unwrap_or_else(|| GdtRun::Text(c.to_string())));
i += 1;
}
c => {
push(&mut rows, GdtRun::Text(c.to_string()));
i += 1;
}
}
}
rows
}
/// A compartment with nothing to show.
pub fn compartment_is_empty(c: &Compartment) -> bool {
c.iter().all(|r| matches!(r, GdtRun::Text(t) if t.trim().is_empty()))
}
/// A tolerance value: optional diameter symbol, the value, optional material condition.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GdtTol {
#[serde(default)]
pub diameter: bool,
#[serde(default)]
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub material: Option<String>,
}
/// A datum reference: the letter(s) and an optional material condition.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GdtDatum {
#[serde(default)]
pub letter: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub material: Option<String>,
}
/// One frame line as the Geometric Tolerance dialog fills it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GdtLine {
pub symbol: Option<GdtSymbol>,
pub tol1: Option<GdtTol>,
pub tol2: Option<GdtTol>,
pub datums: Vec<GdtDatum>,
}
/// A whole feature control frame: up to two tolerance lines, the projected tolerance zone and
/// the datum identifier.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GdtFrame {
pub lines: Vec<GdtLine>,
/// Projected tolerance zone height ("" = none).
pub height: String,
pub projected: bool,
/// Datum identifier ("" = none).
pub datum_id: String,
}
/// Escape free text for a frame string (no line or compartment breaks, no formatting codes).
fn escape(s: &str) -> String {
let mut out = String::new();
for c in s.chars().take(256) {
match c {
'\\' => out.push_str("\\\\"),
'{' => out.push_str("\\{"),
'}' => out.push_str("\\}"),
// `%%%` is a literal percent sign (a lone `%` could start a `%%` code).
'%' => out.push_str("%%%"),
'\n' | '\r' | '^' => out.push(' '),
c => out.push(c),
}
}
out
}
fn sym(s: GdtSymbol) -> String {
format!("{{\\Fgdt;{}}}", s.code())
}
/// The material condition symbol named by `m` ("M", "L", "S", "mmc"…).
fn material(m: &Option<String>) -> Option<GdtSymbol> {
m.as_deref().and_then(GdtSymbol::parse).filter(|s| s.is_material())
}
/// The frame string for `f` (DXF group 1 of a TOLERANCE).
pub fn compose_frame(f: &GdtFrame) -> String {
let mut lines: Vec<String> = Vec::new();
let tol = |t: &Option<GdtTol>| -> String {
let Some(t) = t else { return String::new() };
let mut s = String::new();
if t.diameter {
s.push_str(&sym(GdtSymbol::Diameter));
}
s.push_str(&escape(t.value.trim()));
if let Some(m) = material(&t.material) {
s.push_str(&sym(m));
}
if t.value.trim().is_empty() && !t.diameter && material(&t.material).is_none() { String::new() } else { s }
};
for l in f.lines.iter().take(MAX_FRAME_ROWS) {
let mut cells = vec![l.symbol.map(sym).unwrap_or_default(), tol(&l.tol1), tol(&l.tol2)];
for d in l.datums.iter().take(3) {
let mut s = escape(d.letter.trim());
if let Some(m) = material(&d.material) {
s.push_str(&sym(m));
}
cells.push(s);
}
while cells.last().is_some_and(String::is_empty) {
cells.pop();
}
if !cells.is_empty() {
lines.push(cells.join("%%v"));
}
}
if !f.height.trim().is_empty() || f.projected {
let mut s = escape(f.height.trim());
if f.projected {
s.push_str(&sym(GdtSymbol::Projected));
}
lines.push(s);
}
if !f.datum_id.trim().is_empty() {
lines.push(escape(f.datum_id.trim()));
}
lines.join("^J")
}
/// A primitive of a laid-out frame, in WCS.
#[derive(Clone, Debug, PartialEq)]
pub enum GdtPrim {
Line(Vec<Vec2>),
Circle {
center: Vec2,
radius: f64,
},
/// Counter-clockwise from `start` to `end` (radians).
Arc {
center: Vec2,
radius: f64,
start: f64,
end: f64,
},
/// A filled triangle (arrowheads).
Fill([Vec2; 3]),
}
/// A text run of a laid-out frame: left baseline point (WCS), height and rotation.
#[derive(Clone, Debug, PartialEq)]
pub struct GdtText {
pub pos: Vec2,
pub height: f64,
pub rotation: f64,
pub value: String,
}
/// A laid-out feature control frame.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GdtLayout {
/// The frame's boxes (drawn with the dimension line colour).
pub frame: Vec<GdtPrim>,
/// The symbols (drawn with the text colour).
pub symbols: Vec<GdtPrim>,
pub texts: Vec<GdtText>,
pub bounds: Bounds2,
}
/// Lay out the frame `text` at `insert`, rotated by `rotation`, with text height `h` and
/// horizontal padding `gap`. `measure(s, h)` is the width of plain text `s` at height `h`.
pub fn layout_frame(text: &str, insert: Vec2, rotation: f64, h: f64, gap: f64, measure: &dyn Fn(&str, f64) -> f64) -> GdtLayout {
let h = if h.is_finite() && h > 0.0 { h.min(1e12) } else { 0.18 };
let gap = if gap.is_finite() { gap.abs().clamp(h * 0.25, h * 2.0) } else { h * 0.5 };
let rotation = if rotation.is_finite() { rotation } else { 0.0 };
let (insert, row_h) = (if insert.is_finite() { insert } else { Vec2::ZERO }, 2.0 * h);
let to_w = |p: Vec2| insert + p.rotate(rotation);
let mut out = GdtLayout::default();
let mut prev_width = 0.0;
let rows: Vec<Vec<Compartment>> = parse_frame(text)
.into_iter()
.map(|r| r.into_iter().filter(|c| !compartment_is_empty(c)).collect::<Vec<_>>())
.filter(|r| !r.is_empty())
.collect();
let run_w = |r: &GdtRun| match r {
GdtRun::Symbol(_) => h,
GdtRun::Text(t) => {
let w = measure(t, h);
if w.is_finite() { w.clamp(0.0, h * 4096.0) } else { 0.0 }
}
};
for (k, row) in rows.iter().enumerate() {
let top = h - k as f64 * row_h;
let (bot, mid) = (top - row_h, top - h);
let mut x = 0.0;
for cell in row {
// Runs a little apart, so a modifier doesn't touch the value before it.
let sep = 0.25 * h;
let w: f64 = cell.iter().map(run_w).sum::<f64>() + sep * cell.len().saturating_sub(1) as f64;
let cw = (w + 2.0 * gap).max(row_h);
// The separator on the cell's left (the frame's left edge for the first cell).
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(x, top)), to_w(Vec2::new(x, bot))]));
let mut cx = x + (cw - w) / 2.0;
for r in cell {
match r {
GdtRun::Symbol(s) => {
let o = Vec2::new(cx, mid - h / 2.0);
out.symbols.extend(symbol_prims(*s).into_iter().map(|p| map_prim(p, |q| to_w(o + q * h), h, rotation)));
}
GdtRun::Text(t) => {
out.texts.push(GdtText { pos: to_w(Vec2::new(cx, mid - h / 2.0)), height: h, rotation, value: t.clone() });
}
}
cx += run_w(r) + sep;
}
x += cw;
}
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(x, top)), to_w(Vec2::new(x, bot))]));
// Top edge: the first line's, or the part sticking out past the line above.
if k == 0 {
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(0.0, top)), to_w(Vec2::new(x, top))]));
} else if x > prev_width {
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(prev_width, top)), to_w(Vec2::new(x, top))]));
}
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(0.0, bot)), to_w(Vec2::new(x, bot))]));
out.bounds = out.bounds.union(&Bounds2::from_points([
to_w(Vec2::new(0.0, top)),
to_w(Vec2::new(x, bot)),
to_w(Vec2::new(0.0, bot)),
to_w(Vec2::new(x, top)),
]));
prev_width = x;
}
if rows.is_empty() {
out.bounds = Bounds2::from_points([insert]);
}
out
}
/// Map a unit-box primitive through `f` (scale `s`, rotation `rot`).
fn map_prim(p: GdtPrim, f: impl Fn(Vec2) -> Vec2, s: f64, rot: f64) -> GdtPrim {
match p {
GdtPrim::Line(v) => GdtPrim::Line(v.into_iter().map(f).collect()),
GdtPrim::Circle { center, radius } => GdtPrim::Circle { center: f(center), radius: radius * s },
GdtPrim::Arc { center, radius, start, end } => GdtPrim::Arc { center: f(center), radius: radius * s, start: start + rot, end: end + rot },
GdtPrim::Fill(t) => GdtPrim::Fill(t.map(f)),
}
}
/// An arrowhead at `tip` pointing along `d` (unit box units).
fn arrow(tip: Vec2, d: Vec2) -> GdtPrim {
let d = d.normalized();
let back = tip - d * 0.3;
GdtPrim::Fill([tip, back + d.perp() * 0.1, back - d.perp() * 0.1])
}
/// A symbol drawn in a unit box (x and y in 0..1, the baseline at y = 0).
pub fn symbol_prims(s: GdtSymbol) -> Vec<GdtPrim> {
use std::f64::consts::{FRAC_PI_2, PI};
let v = Vec2::new;
let line = |pts: &[(f64, f64)]| GdtPrim::Line(pts.iter().map(|(x, y)| v(*x, *y)).collect());
let circle = |x: f64, y: f64, r: f64| GdtPrim::Circle { center: v(x, y), radius: r };
// Circled letters: the circle plus the letter's strokes.
let circled = |mut letter: Vec<GdtPrim>| {
letter.insert(0, circle(0.5, 0.5, 0.5));
letter
};
match s {
GdtSymbol::Position => vec![circle(0.5, 0.5, 0.3), line(&[(0.0, 0.5), (1.0, 0.5)]), line(&[(0.5, 0.0), (0.5, 1.0)])],
GdtSymbol::Concentricity => vec![circle(0.5, 0.5, 0.45), circle(0.5, 0.5, 0.25)],
GdtSymbol::Symmetry => vec![line(&[(0.0, 0.5), (1.0, 0.5)]), line(&[(0.2, 0.2), (0.8, 0.2)]), line(&[(0.2, 0.8), (0.8, 0.8)])],
GdtSymbol::Parallelism => vec![line(&[(0.1, 0.05), (0.5, 0.95)]), line(&[(0.5, 0.05), (0.9, 0.95)])],
GdtSymbol::Perpendicularity => vec![line(&[(0.05, 0.05), (0.95, 0.05)]), line(&[(0.5, 0.05), (0.5, 0.95)])],
GdtSymbol::Angularity => vec![line(&[(0.95, 0.05), (0.05, 0.05), (0.85, 0.75)])],
GdtSymbol::Cylindricity => {
let (c, r) = (v(0.5, 0.5), 0.3);
let d = Vec2::from_angle(65f64.to_radians());
let n = d.perp() * r;
vec![circle(0.5, 0.5, r), GdtPrim::Line(vec![c + n - d * 0.5, c + n + d * 0.5]), GdtPrim::Line(vec![c - n - d * 0.5, c - n + d * 0.5])]
}
GdtSymbol::Flatness => vec![line(&[(0.0, 0.25), (0.7, 0.25), (1.0, 0.75), (0.3, 0.75), (0.0, 0.25)])],
GdtSymbol::Circularity => vec![circle(0.5, 0.5, 0.45)],
GdtSymbol::Straightness => vec![line(&[(0.0, 0.5), (1.0, 0.5)])],
GdtSymbol::SurfaceProfile => {
vec![GdtPrim::Arc { center: v(0.5, 0.2), radius: 0.45, start: 0.0, end: PI }, line(&[(0.05, 0.2), (0.95, 0.2)])]
}
GdtSymbol::LineProfile => vec![GdtPrim::Arc { center: v(0.5, 0.2), radius: 0.45, start: 0.0, end: PI }],
GdtSymbol::CircularRunout => vec![line(&[(0.2, 0.05), (0.7, 0.95)]), arrow(v(0.7, 0.95), v(0.5, 0.9))],
GdtSymbol::TotalRunout => vec![
line(&[(0.05, 0.05), (0.5, 0.05)]),
line(&[(0.05, 0.05), (0.45, 0.95)]),
line(&[(0.5, 0.05), (0.9, 0.95)]),
arrow(v(0.45, 0.95), v(0.4, 0.9)),
arrow(v(0.9, 0.95), v(0.4, 0.9)),
],
GdtSymbol::Diameter => vec![circle(0.5, 0.5, 0.35), line(&[(0.1, 0.0), (0.9, 1.0)])],
GdtSymbol::Mmc => circled(vec![line(&[(0.3, 0.25), (0.3, 0.75), (0.5, 0.45), (0.7, 0.75), (0.7, 0.25)])]),
GdtSymbol::Lmc => circled(vec![line(&[(0.38, 0.75), (0.38, 0.25), (0.65, 0.25)])]),
GdtSymbol::Rfs => circled(vec![
GdtPrim::Arc { center: v(0.5, 0.625), radius: 0.125, start: 20f64.to_radians(), end: 3.0 * FRAC_PI_2 },
GdtPrim::Arc { center: v(0.5, 0.375), radius: 0.125, start: 200f64.to_radians(), end: FRAC_PI_2 },
]),
GdtSymbol::Projected => circled(vec![
line(&[(0.5, 0.5), (0.38, 0.5), (0.38, 0.75), (0.5, 0.75)]),
GdtPrim::Arc { center: v(0.5, 0.625), radius: 0.125, start: -FRAC_PI_2, end: FRAC_PI_2 },
line(&[(0.38, 0.5), (0.38, 0.25)]),
]),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_symbols_compartments_and_lines() {
let rows = parse_frame("{\\Fgdt;j}%%v{\\Fgdt;n}0.1{\\Fgdt;m}%%vA%%vB^J2.5{\\Fgdt;p}");
assert_eq!(rows.len(), 2);
let r0 = &rows[0];
assert_eq!(r0.len(), 4);
assert_eq!(r0[0], vec![GdtRun::Symbol(GdtSymbol::Position)]);
assert_eq!(r0[1], vec![GdtRun::Symbol(GdtSymbol::Diameter), GdtRun::Text("0.1".into()), GdtRun::Symbol(GdtSymbol::Mmc)]);
assert_eq!(r0[3], vec![GdtRun::Text("B".into())]);
assert_eq!(rows[1][0], vec![GdtRun::Text("2.5".into()), GdtRun::Symbol(GdtSymbol::Projected)]);
}
#[test]
fn compose_then_parse() {
let f = GdtFrame {
lines: vec![GdtLine {
symbol: Some(GdtSymbol::Flatness),
tol1: Some(GdtTol { diameter: false, value: "0.05".into(), material: None }),
tol2: None,
datums: vec![GdtDatum { letter: "A".into(), material: Some("L".into()) }],
}],
height: String::new(),
projected: false,
datum_id: "B".into(),
};
let s = compose_frame(&f);
assert_eq!(s, "{\\Fgdt;c}%%v0.05%%v%%vA{\\Fgdt;l}^JB");
let rows = parse_frame(&s);
assert_eq!(rows[0][3], vec![GdtRun::Text("A".into()), GdtRun::Symbol(GdtSymbol::Lmc)]);
assert_eq!(rows[1][0], vec![GdtRun::Text("B".into())]);
}
#[test]
fn hostile_strings_do_not_panic() {
let huge = "%%v{\\Fgdt;j}".repeat(100_000);
for s in ["{\\Fgdt;", "{\\Fgdt", "\\", "%%", "%", "{{{{", "}}}}", "^", "\\P\\P\\P", "{\\H", &huge, "\u{0}%%v\u{ffff}"] {
let rows = parse_frame(s);
assert!(rows.len() <= MAX_FRAME_ROWS && rows.iter().all(|r| r.len() <= MAX_FRAME_CELLS));
let l = layout_frame(s, Vec2::new(f64::NAN, 1.0), f64::INFINITY, f64::NAN, -1.0, &|t, h| t.len() as f64 * h);
assert!(l.frame.iter().all(|p| matches!(p, GdtPrim::Line(v) if v.iter().all(|q| q.is_finite()))));
}
}
#[test]
fn layout_boxes_each_compartment() {
let l = layout_frame("{\\Fgdt;j}%%v0.1%%vA", Vec2::ZERO, 0.0, 1.0, 0.5, &|t, h| t.chars().count() as f64 * h);
// Three cells: four separators, top and bottom.
assert_eq!(l.frame.len(), 6);
assert_eq!(l.texts.len(), 2);
assert!((l.bounds.min.y + 1.0).abs() < 1e-9 && (l.bounds.max.y - 1.0).abs() < 1e-9);
assert!(l.bounds.min.x.abs() < 1e-9 && l.bounds.max.x > 6.0);
}
}
+2
View File
@@ -9,6 +9,7 @@
mod constraint;
mod entity;
mod extents;
mod gdt;
mod header;
pub mod library;
mod plotstyle;
@@ -22,6 +23,7 @@ pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
pub use gdt::*;
pub use header::{HVal, Header};
pub use plotstyle::*;
pub use store::EntityStore;
+5 -1
View File
@@ -177,7 +177,11 @@ fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
.collect()
}
Kind::Dimstyles => {
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.style.clone()) } else { None }));
let used = lower(&mut entities(d).filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.style.clone()),
EntityKind::Tolerance(t) => Some(t.style.clone()),
_ => None,
}));
let cur = d.header.str("DIMSTYLE", "Standard");
d.dim_styles
.iter()
+3
View File
@@ -211,6 +211,9 @@ impl Used {
EntityKind::Leader(l) => {
self.dim_styles.insert(up(&l.style));
}
EntityKind::Tolerance(t) => {
self.dim_styles.insert(up(&t.style));
}
EntityKind::MLeader(m) => {
self.mleader_styles.insert(up(&m.style));
self.text_styles.extend(m.text.iter().map(|t| up(&t.style)));
+2
View File
@@ -28,6 +28,7 @@ mod qselect;
mod settings;
mod spline_opts;
mod table;
mod tolerance;
mod trimextend;
mod utility;
mod view;
@@ -180,6 +181,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(settings::specs());
v.extend(utility::specs());
v.extend(table::specs());
v.extend(tolerance::specs());
v.extend(constraints::specs());
v
})
+1
View File
@@ -1256,6 +1256,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
}
Some(v)
}
EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)),
_ => None,
}
}
+4
View File
@@ -1439,6 +1439,10 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
i.v.z = 0.0;
}
EntityKind::Table(t) => z(&mut t.insert),
EntityKind::Tolerance(t) => {
z(&mut t.insert);
t.dir.z = 0.0;
}
EntityKind::Spline(_) | EntityKind::Viewport(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
}
}
+2
View File
@@ -693,6 +693,7 @@ fn dims_using(d: &cadcraft_doc::Drawing, name: &str) -> Vec<(cadcraft_doc::Space
let uses = match &e.kind {
EntityKind::Dimension(dm) => dm.style.eq_ignore_ascii_case(name),
EntityKind::Leader(l) => l.style.eq_ignore_ascii_case(name),
EntityKind::Tolerance(t) => t.style.eq_ignore_ascii_case(name),
_ => false,
};
if uses {
@@ -723,6 +724,7 @@ fn run_dimstyle_rename(s: &mut Session, p: &Value) -> Result<Value> {
d.modify_entity(h, |e| match &mut e.kind {
EntityKind::Dimension(dm) => dm.style = to.clone(),
EntityKind::Leader(l) => l.style = to.clone(),
EntityKind::Tolerance(t) => t.style = to.clone(),
_ => {}
})?;
}
+312
View File
@@ -0,0 +1,312 @@
//! TOLERANCE: geometric tolerances (feature control frames) and their explosion.
use cadcraft_doc::{Drawing, Entity, EntityKind, GdtDatum, GdtFrame, GdtLine, GdtPrim, GdtSymbol, GdtTol, Handle, MAX_FRAME_CHARS, Tolerance};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::{Value, json};
use super::helpers::{line, lwpoly, v3};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("tolerance", "Tolerance...", run_tolerance)
.menu(&["Dimension", "Tolerance..."])
.alias(&["tol"])
.params(
"{at: [x,y], symbol?: position|concentricity|symmetry|parallelism|perpendicularity|angularity|cylindricity|flatness|circularity|straightness|surfaceprofile|lineprofile|circularrunout|totalrunout, tol1?: \"%%c0.1(M)\" | {value, diameter?, material?: M|L|S}, tol2?, datums?: [\"A(M)\", \"B\", {letter, material?}] (up to 3), line2?: {symbol?, tol1?, tol2?, datums?}, height? (projected zone), projected?, datumId?, rotation? (degrees), style? (dimension style; default DIMSTYLE), text? (the frame string as in DXF, instead of the fields)}",
)
.interactive(|s| Ok(Box::new(ToleranceM::new(s)))),
]
}
/// A tolerance typed as text: an optional diameter prefix (`%%c`, `Ø`, `⌀`, `dia`), the value and
/// an optional material condition suffix (`(M)`, `(L)`, `(S)`).
pub fn parse_tol(s: &str) -> GdtTol {
let mut t = s.trim();
let mut diameter = false;
for p in ["%%c", "%%C", "Ø", "ø", "⌀", "dia ", "DIA "] {
if let Some(r) = t.strip_prefix(p) {
diameter = true;
t = r.trim_start();
break;
}
}
let (rest, material) = split_material(t);
GdtTol { diameter, value: rest.to_string(), material }
}
/// A datum reference typed as text: the letter(s) and an optional `(M)` / `(L)` / `(S)` suffix.
pub fn parse_datum(s: &str) -> GdtDatum {
let (letter, material) = split_material(s.trim());
GdtDatum { letter: letter.to_string(), material }
}
fn split_material(t: &str) -> (&str, Option<String>) {
let up = t.to_ascii_uppercase();
for m in ["M", "L", "S"] {
if up.ends_with(&format!("({m})")) {
return (t.get(..t.len().saturating_sub(3)).unwrap_or("").trim_end(), Some(m.to_string()));
}
}
(t, None)
}
fn tol_value(v: Option<&Value>) -> Option<GdtTol> {
match v? {
Value::String(s) => Some(parse_tol(s)),
Value::Number(n) => Some(GdtTol { value: n.to_string(), ..Default::default() }),
o @ Value::Object(_) => Some(GdtTol {
diameter: bool_or(o, "diameter", false),
value: match o.get("value") {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => String::new(),
},
material: str_param(o, "material").map(str::to_string),
}),
_ => None,
}
}
fn datums_value(v: Option<&Value>) -> Vec<GdtDatum> {
let Some(a) = v.and_then(Value::as_array) else { return Vec::new() };
a.iter()
.take(3)
.filter_map(|d| match d {
Value::String(s) => Some(parse_datum(s)),
o @ Value::Object(_) => {
Some(GdtDatum { letter: str_param(o, "letter").unwrap_or("").to_string(), material: str_param(o, "material").map(str::to_string) })
}
_ => None,
})
.collect()
}
fn symbol_value(cmd: &str, v: Option<&Value>) -> Result<Option<GdtSymbol>> {
let Some(s) = v.and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty() && !s.eq_ignore_ascii_case("none")) else {
return Ok(None);
};
match GdtSymbol::parse(s).or_else(|| s.chars().next().filter(|_| s.chars().count() == 1).and_then(GdtSymbol::from_code)) {
Some(sym) if sym.is_characteristic() => Ok(Some(sym)),
_ => Err(bad(cmd, format!("unknown geometric characteristic `{s}`"))),
}
}
fn line_value(cmd: &str, p: &Value) -> Result<GdtLine> {
Ok(GdtLine {
symbol: symbol_value(cmd, p.get("symbol"))?,
tol1: tol_value(p.get("tol1")),
tol2: tol_value(p.get("tol2")),
datums: datums_value(p.get("datums")),
})
}
/// The frame string the parameters describe.
fn frame_text(p: &Value) -> Result<String> {
if let Some(t) = str_param(p, "text") {
return Ok(t.chars().take(MAX_FRAME_CHARS).collect());
}
let mut lines = vec![line_value("tolerance", p)?];
if let Some(l2) = p.get("line2").filter(|v| v.is_object()) {
lines.push(line_value("tolerance", l2)?);
}
let height = match p.get("height") {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => String::new(),
};
let f = GdtFrame { lines, height, projected: bool_or(p, "projected", false), datum_id: str_param(p, "datumId").unwrap_or("").to_string() };
Ok(cadcraft_doc::compose_frame(&f))
}
fn current_dimstyle(s: &Session) -> String {
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
}
fn make(style: String, at: Vec2, rotation: f64, text: String) -> Tolerance {
Tolerance { insert: v3(at), dir: Vec2::from_angle(rotation).to3(0.0), style, text }
}
fn run_tolerance(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("tolerance", p, "at")?;
let text = frame_text(p)?;
if cadcraft_doc::parse_frame(&text).iter().flatten().all(cadcraft_doc::compartment_is_empty) {
return Err(bad("tolerance", "the feature control frame is empty"));
}
let style = str_param(p, "style").map(str::to_string).unwrap_or_else(|| current_dimstyle(s));
if s.doc()?.dim_style(&style).is_none() {
return Err(bad("tolerance", format!("no dimension style `{style}`")));
}
let t = make(style, at, f64_or(p, "rotation", 0.0).to_radians(), text.clone());
let h = s.add_entity(EntityKind::Tolerance(t))?;
Ok(json!({ "handle": h.hex(), "text": text }))
}
/// TOLERANCE at the command line: characteristic, two tolerance values, up to three datums,
/// then the location.
struct ToleranceM {
step: usize,
line: GdtLine,
style: String,
}
const SYMBOL_KW: &[&str] = &[
"POsition",
"COncentricity",
"SYmmetry",
"PArallelism",
"PErpendicularity",
"ANgularity",
"CYlindricity",
"Flatness",
"CIrcularity",
"STraightness",
"SUrface",
"LIne",
"Runout",
"TOtal",
"None",
];
/// Free input split at spaces, Enter meaningful.
const VALUE: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
impl ToleranceM {
fn new(s: &Session) -> Self {
ToleranceM { step: 0, line: GdtLine { symbol: Some(GdtSymbol::Position), ..Default::default() }, style: current_dimstyle(s) }
}
fn text(&self) -> String {
cadcraft_doc::compose_frame(&GdtFrame { lines: vec![self.line.clone()], ..Default::default() })
}
fn at_location(&mut self, s: &mut Session) -> Step {
self.step = 6;
if cadcraft_doc::parse_frame(&self.text()).iter().flatten().all(cadcraft_doc::compartment_is_empty) {
s.echo("The feature control frame is empty.");
return Step::Cancel;
}
Step::Continue
}
}
impl Interactive for ToleranceM {
fn name(&self) -> &'static str {
"TOLERANCE"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.step {
0 => Prompt::new("Enter geometric characteristic", VALUE).kw(SYMBOL_KW).default("Position"),
1 => Prompt::new("Enter first tolerance value", VALUE).default("none"),
2 => Prompt::new("Enter second tolerance value", VALUE).default("none"),
n @ 3..=5 => Prompt::new(format!("Enter datum reference {}", n - 2), VALUE).default("none"),
_ => Prompt::new("Enter tolerance location", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if i == Input::Cancel {
return Ok(Step::Cancel);
}
let text = match &i {
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()),
_ => None,
};
let none = |t: &Option<String>| t.as_deref().is_none_or(|t| t.is_empty() || t.eq_ignore_ascii_case("none"));
match self.step {
0 => {
match (&i, &text) {
(Input::Enter, _) => {}
(_, Some(t)) if t.eq_ignore_ascii_case("none") => self.line.symbol = None,
(_, Some(t)) => match GdtSymbol::parse(t).filter(|g| g.is_characteristic()) {
Some(g) => self.line.symbol = Some(g),
None => {
s.echo("Requires a geometric characteristic or None.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
}
self.step = 1;
}
1 | 2 => {
if !matches!(i, Input::Enter | Input::Text(_) | Input::Keyword(_)) {
return Ok(Step::Continue);
}
let v = (!none(&text)).then(|| parse_tol(text.as_deref().unwrap_or("")));
if self.step == 1 {
self.line.tol1 = v;
} else {
self.line.tol2 = v;
}
self.step += 1;
}
3..=5 => {
if !matches!(i, Input::Enter | Input::Text(_) | Input::Keyword(_)) {
return Ok(Step::Continue);
}
if none(&text) {
return Ok(self.at_location(s));
}
self.line.datums.push(parse_datum(text.as_deref().unwrap_or("")));
if self.step == 5 {
return Ok(self.at_location(s));
}
self.step += 1;
}
_ => match i {
Input::Point(p) => {
let t = make(self.style.clone(), p, 0.0, self.text());
s.add_entity(EntityKind::Tolerance(t))?;
return Ok(Step::Done);
}
Input::Enter => return Ok(Step::Cancel),
_ => {}
},
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.step == 6 { vec![EntityKind::Tolerance(make(self.style.clone(), c, 0.0, self.text()))] } else { Vec::new() }
}
}
/// EXPLODE of a feature control frame: lines, circles, arcs, solids (arrowheads) and texts in the
/// dimension style's text style.
pub(crate) fn explode(d: &Drawing, e: &Entity, t: &Tolerance) -> Vec<Entity> {
let g = cadcraft_render::tolerance_in(d, t);
let st = d.dim_style(&t.style).cloned().unwrap_or_default();
let wf = d.text_style(&st.text_style).map(|s| s.width_factor).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(1.0);
let mk = |kind| Entity { handle: Handle(0), common: e.common.clone(), kind };
let mut v = Vec::new();
for p in g.frame.iter().chain(&g.symbols) {
match p {
GdtPrim::Line(pts) => match pts.as_slice() {
[a, b] => v.push(mk(line(*a, *b))),
pts if pts.len() > 2 => v.push(mk(lwpoly(pts.iter().map(|q| PolyVertex::new(*q)).collect(), false))),
_ => {}
},
GdtPrim::Circle { center, radius } => v.push(mk(super::helpers::circle(*center, *radius))),
GdtPrim::Arc { center, radius, start, end } => v.push(mk(EntityKind::Arc(cadcraft_doc::Arc {
center: v3(*center),
radius: *radius,
start: cadcraft_geom::norm_angle(*start),
end: cadcraft_geom::norm_angle(*end),
}))),
GdtPrim::Fill([a, b, c]) => v.push(mk(EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }))),
}
}
for tx in &g.texts {
v.push(mk(EntityKind::Text(cadcraft_doc::Text {
insert: v3(tx.pos),
align_pt: None,
height: tx.height,
value: tx.value.clone(),
rotation: tx.rotation,
width_factor: wf,
oblique: 0.0,
style: st.text_style.clone(),
halign: Default::default(),
valign: Default::default(),
})));
}
v
}
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
EntityKind::Point(_) | EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Image(_) | EntityKind::Table(_) => {
return Some(translated(kind, delta));
}
EntityKind::Hatch(_) => return Some(translated(kind, delta)),
EntityKind::Hatch(_) | EntityKind::Tolerance(_) => return Some(translated(kind, delta)),
EntityKind::Circle(c) => {
if index == 0 {
return Some(translated(kind, delta));
+2
View File
@@ -15,6 +15,7 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
| EntityKind::Dimension(_)
| EntityKind::AttDef(_)
| EntityKind::Table(_)
| EntityKind::Tolerance(_)
| EntityKind::MLeader(_)
| EntityKind::Image(_) => {
// Text-like objects are hit inside their box.
@@ -75,6 +76,7 @@ pub fn entity_distance(d: &Drawing, e: &Entity, p: Vec2, tol: f64) -> f64 {
let polys = hit_polylines(d, e, tol);
let mut best = polys.iter().map(|pl| dist_to_polyline(pl, p)).fold(f64::INFINITY, f64::min);
let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Solid(_) | EntityKind::Table(_))
|| matches!(e.kind, EntityKind::Tolerance(_))
|| matches!(&e.kind, EntityKind::Hatch(h) if h.solid);
if filled && entity_bounds(d, e, 0).contains(p) {
best = 0.0;
+1
View File
@@ -219,6 +219,7 @@ pub(crate) fn gather(
match &e.kind {
EntityKind::Text(t) => cands.push((mode::INS, t.justify_point())),
EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())),
EntityKind::Tolerance(t) => cands.push((mode::INS, t.insert.xy())),
EntityKind::Insert(i) => {
cands.push((mode::INS, i.insert.xy()));
cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.justify_point())));
+152
View File
@@ -0,0 +1,152 @@
//! TOLERANCE (#420): feature control frames from the JSON form and the command line, edited by
//! MOVE / ROTATE / MIRROR, exploded into plain geometry and text, hostile input refused.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Tolerance};
use cadcraft_engine::geom::Vec3;
use serde_json::json;
fn tolerance(s: &Session) -> (String, Tolerance) {
let d = s.doc().unwrap();
d.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::Tolerance(t) => Some((e.handle.hex(), t.clone())),
_ => None,
})
.expect("a tolerance")
}
fn near(a: Vec3, b: Vec3) -> bool {
(a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() < 1e-9
}
#[test]
fn json_form_composes_the_frame() {
let mut s = Session::new();
let r = s
.execute(
"tolerance",
&json!({
"at": [10, 20],
"symbol": "position",
"tol1": "%%c0.1(M)",
"datums": ["A", "B(L)", { "letter": "C", "material": "S" }],
"line2": { "symbol": "flatness", "tol1": { "value": 0.02 } },
"height": "2.5",
"projected": true,
"datumId": "D",
"rotation": 90
}),
)
.unwrap();
let want = "{\\Fgdt;j}%%v{\\Fgdt;n}0.1{\\Fgdt;m}%%v%%vA%%vB{\\Fgdt;l}%%vC{\\Fgdt;s}^J{\\Fgdt;c}%%v0.02^J2.5{\\Fgdt;p}^JD";
assert_eq!(r["text"], want);
let (h, t) = tolerance(&s);
assert_eq!(r["handle"], h);
assert_eq!(t.text, want);
assert_eq!(t.style, "Standard");
assert!(near(t.insert, Vec3::new(10.0, 20.0, 0.0)) && near(t.dir, Vec3::new(0.0, 1.0, 0.0)), "{t:?}");
}
#[test]
fn command_line_prompts() {
let mut s = Session::new();
// Characteristic, two tolerance values (the second skipped), datums until an empty input,
// then the location.
s.script("TOLERANCE\nposition\n%%c0.1(M)\n\nA\nB(M)\n\n5,5\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (_, t) = tolerance(&s);
assert_eq!(t.text, "{\\Fgdt;j}%%v{\\Fgdt;n}0.1{\\Fgdt;m}%%v%%vA%%vB{\\Fgdt;m}");
assert!(near(t.insert, Vec3::new(5.0, 5.0, 0.0)));
// One line with keyword shortcuts; "none" skips a value, the third datum ends the prompts.
let mut s = Session::new();
s.cmdline("tol F 0.05 none A B C 1,2").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (_, t) = tolerance(&s);
assert_eq!(t.text, "{\\Fgdt;c}%%v0.05%%v%%vA%%vB%%vC");
assert!(near(t.insert, Vec3::new(1.0, 2.0, 0.0)));
}
#[test]
fn command_line_rejects_bad_symbols_and_empty_frames() {
let mut s = Session::new();
s.cmdline("tolerance").unwrap();
s.cmdline("mmc").unwrap();
// A material condition is no characteristic: the prompt repeats.
assert!(s.prompt_text().contains("geometric characteristic"), "{}", s.prompt_text());
s.script("None\n\n\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(s.doc().unwrap().model.iter().all(|e| !matches!(e.kind, EntityKind::Tolerance(_))));
}
#[test]
fn transforms_and_grips() {
let mut s = Session::new();
s.execute("tolerance", &json!({ "at": [1, 1], "symbol": "flatness", "tol1": "0.1" })).unwrap();
let (h, t0) = tolerance(&s);
assert_eq!(EntityKind::Tolerance(t0.clone()).grips().len(), 1);
s.execute("move", &json!({ "handles": [h], "delta": [2, 3] })).unwrap();
assert!(near(tolerance(&s).1.insert, Vec3::new(3.0, 4.0, 0.0)));
s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 90 })).unwrap();
let t = tolerance(&s).1;
assert!(near(t.insert, Vec3::new(-4.0, 3.0, 0.0)) && near(t.dir, Vec3::new(0.0, 1.0, 0.0)), "{t:?}");
// Mirrored frames keep reading the same way (like text with MIRRTEXT 0).
s.execute("mirror", &json!({ "handles": [h], "p1": [0, 0], "p2": [0, 1], "erase": true })).unwrap();
let t = tolerance(&s).1;
assert!(near(t.insert, Vec3::new(4.0, 3.0, 0.0)) && near(t.dir, Vec3::new(0.0, 1.0, 0.0)), "{t:?}");
assert_eq!(t.text, t0.text);
}
#[test]
fn explode_gives_lines_symbols_and_text() {
let mut s = Session::new();
s.execute("tolerance", &json!({ "at": [0, 0], "symbol": "position", "tol1": "%%c0.1(M)", "datums": ["A"] })).unwrap();
let (h, _) = tolerance(&s);
let r = s.execute("explode", &json!({ "handles": [h] })).unwrap();
assert!(r["handles"].as_array().unwrap().len() > 8, "{r}");
let d = s.doc().unwrap();
let kinds: Vec<&EntityKind> = d.model.iter().map(|e| &e.kind).collect();
assert!(!kinds.iter().any(|k| matches!(k, EntityKind::Tolerance(_))));
let texts: Vec<&str> = kinds
.iter()
.filter_map(|k| match k {
EntityKind::Text(t) => Some(t.value.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, ["0.1", "A"]);
// Position and diameter symbols and the circled M: circles; the boxes: lines.
assert_eq!(kinds.iter().filter(|k| matches!(k, EntityKind::Circle(_))).count(), 3);
assert!(kinds.iter().filter(|k| matches!(k, EntityKind::Line(_))).count() >= 6);
}
#[test]
fn hostile_params_are_refused_without_panicking() {
let mut s = Session::new();
for p in [
json!({ "at": [0, 0] }),
json!({ "at": [0, 0], "symbol": "nonsense" }),
json!({ "at": [0, 0], "symbol": "mmc" }),
json!({ "symbol": "position" }),
json!({ "at": [0, 0], "symbol": "position", "style": "NoSuchStyle" }),
json!({ "at": [0, 0], "text": "" }),
] {
assert!(s.execute("tolerance", &p).is_err(), "{p}");
}
let huge = "{\\Fgdt;j}%%v".repeat(100_000);
for p in [
json!({ "at": [0, 0], "text": "{\\Fgdt;" }),
json!({ "at": [0, 0], "text": "x{\\Fgdt;j" }),
json!({ "at": [0, 0], "text": huge }),
json!({ "at": [0, 0], "symbol": "position", "tol1": "x".repeat(100_000), "datums": [1, null, {}, "A"], "rotation": 1e308 }),
json!({ "at": [1e308, -1e308], "tol1": { "value": "%%v^J{}\\" }, "height": 1e300 }),
] {
if s.execute("tolerance", &p).is_ok() {
let t = tolerance(&s).1;
assert!(t.text.chars().count() <= cadcraft_engine::doc::MAX_FRAME_CHARS);
let _ = s.execute("explode", &json!({ "handles": [tolerance(&s).0] }));
}
}
}
+10
View File
@@ -442,6 +442,16 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
}
// The file path comes from the IMAGEDEF object (`dxf_image::resolve`).
"IMAGE" => EntityKind::Image(crate::dxf_image::read_entity(tags)),
"TOLERANCE" => {
let dir = t.p(11);
let dir = if dir.xy().is_finite() && dir.xy().len() > 1e-12 { dir } else { Vec3::new(1.0, 0.0, 0.0) };
EntityKind::Tolerance(Tolerance {
insert: t.p(10),
dir,
style: t.s(3).unwrap_or_else(|| "Standard".into()),
text: t.s(1).unwrap_or_default().chars().take(MAX_FRAME_CHARS).collect(),
})
}
other => EntityKind::Unknown(Unknown {
dxf_type: other.to_string(),
tags: tags.iter().map(|tg| RawTag { code: tg.code, value: tg.str() }).collect(),
+8
View File
@@ -924,6 +924,14 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy));
}
}
EntityKind::Tolerance(t) => {
w.s(0, "TOLERANCE");
common(w, e, owner, paper, "AcDbFcf");
w.s(3, &t.style);
w.p(10, t.insert);
w.s(1, &t.text);
w.p(11, t.dir);
}
// Entity types CADCraft doesn't model aren't written; the save command reports them.
EntityKind::Unknown(_) => {}
}
+78
View File
@@ -0,0 +1,78 @@
//! TOLERANCE entities (feature control frames, DXF Reference group codes 3, 10, 1, 11) are read,
//! drawn, written and read back; hostile frame strings never panic.
use cadcraft_doc::*;
use cadcraft_geom::Vec3;
use cadcraft_io::{read_dxf, write_dxf};
const FRAME: &str = "{\\Fgdt;j}%%v{\\Fgdt;n}0.1{\\Fgdt;m}%%vA%%vB{\\Fgdt;l}%%vC^J{\\Fgdt;j}%%v0.05%%vA";
fn dxf(text: &str, dir: (&str, &str)) -> String {
format!(
"0\nSECTION\n2\nENTITIES\n0\nTOLERANCE\n5\n2A\n8\nGDT\n100\nAcDbEntity\n100\nAcDbFcf\n3\nStandard\n10\n5\n20\n6\n30\n0\n1\n{text}\n11\n{}\n21\n{}\n31\n0\n0\nENDSEC\n0\nEOF\n",
dir.0, dir.1
)
}
fn tolerances(d: &Drawing) -> Vec<(Common, Tolerance)> {
d.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Tolerance(t) => Some((e.common.clone(), t.clone())),
_ => None,
})
.collect()
}
#[test]
fn tolerance_reads_draws_and_roundtrips() {
let d = read_dxf(dxf(FRAME, ("0", "1")).as_bytes()).expect("read");
let ts = tolerances(&d);
assert_eq!(ts.len(), 1, "read as a TOLERANCE, not kept as unknown data");
let (common, t) = &ts[0];
assert_eq!(common.layer, "GDT");
assert_eq!((t.insert, t.dir, t.style.as_str(), t.text.as_str()), (Vec3::new(5.0, 6.0, 0.0), Vec3::new(0.0, 1.0, 0.0), "Standard", FRAME));
assert!((t.rotation() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
// Drawn: boxes, symbols and text, turned a quarter turn about the insertion point: the frame
// grows along +Y from it, its first line straddles x = 5 and the second lies at larger x.
let list = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
assert!(list.prims.len() > 20, "{} prims", list.prims.len());
let b = d.extents(&Space::Model);
let (h, _) = t.text_size(&d);
assert!((b.min.y - 6.0).abs() < 1e-9 && b.max.y > 6.0 + 8.0 * h, "{b:?}");
assert!((b.min.x - (5.0 - h)).abs() < 1e-9 && (b.max.x - (5.0 + 3.0 * h)).abs() < 1e-9, "{b:?}");
assert!((list.bounds.min.y - 6.0).abs() < 1e-6 && list.bounds.max.x <= 5.0 + 3.0 * h + 1e-6, "{:?}", list.bounds);
let again = read_dxf(write_dxf(&d).as_bytes()).expect("reopen");
assert_eq!(tolerances(&again), ts);
}
#[test]
fn missing_direction_defaults_to_x() {
let text = dxf("A", ("0", "0"));
let d = read_dxf(text.as_bytes()).expect("read");
assert_eq!(tolerances(&d)[0].1.dir, Vec3::new(1.0, 0.0, 0.0));
}
#[test]
fn hostile_tolerance_does_not_panic() {
let huge = "{\\Fgdt;j}%%v".repeat(200_000);
let deep = "{".repeat(100_000);
for (text, dir) in [
("{\\Fgdt;", ("1", "0")),
("{\\Fgdt", ("nan", "inf")),
("\\", ("1e308", "1e308")),
("%%", ("-0", "0")),
("^J^J^J^J^J^J^J^J^J^J^J^J", ("1", "0")),
(huge.as_str(), ("1", "0")),
(deep.as_str(), ("1", "0")),
("{\\H1e308x;\\Fgdt;jjjjjjjjjjjjjjjjjjjjjjjjjj}", ("1", "0")),
] {
if let Ok(d) = read_dxf(dxf(text, dir).as_bytes()) {
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let _ = d.extents(&Space::Model);
let _ = read_dxf(write_dxf(&d).as_bytes());
}
}
}
+3
View File
@@ -17,6 +17,7 @@ pub mod paper;
mod plotstyle;
mod point;
pub mod raster;
mod tolerance;
pub mod units;
mod wide;
@@ -27,6 +28,7 @@ use cadcraft_geom::{Bounds2, Mat3, Polyline, Vec2};
pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, dimension_geometry, dimension_geometry_with, format_linear_value};
pub use fill::triangulate_evenodd;
pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
pub use tolerance::{gdt_points, tolerance_in};
/// Handle carried by model-space geometry drawn inside a paper-space viewport (it belongs to no
/// single paper-space entity, so selection highlighting never matches it).
@@ -669,6 +671,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
b.polyline(ctx, rgb, lw, &[o + u, o + v]);
}
EntityKind::Table(t) => table(b, ctx, t, rgb, lw),
EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol),
EntityKind::Leader(l) => {
let pts: Vec<Vec2> = l.vertices.iter().map(|v| v.xy()).collect();
stroke(b, &pts);
+70
View File
@@ -0,0 +1,70 @@
//! TOLERANCE: feature control frames. The layout lives in `cadcraft_doc::gdt`; here the text is
//! measured with the dimension style's font and everything is drawn: the boxes with DIMCLRD,
//! symbols and text with DIMCLRT (ByBlock = the entity's own colour, as for dimensions).
use cadcraft_color::Color;
use cadcraft_doc::{Drawing, Entity, GdtLayout, GdtPrim, Tolerance};
use cadcraft_geom::{Arc as GArc, Circle as GCircle, Segment, Vec2};
use crate::{Builder, Ctx, Ink, ink};
/// The frame laid out with the font of its dimension style's text style.
pub fn tolerance_in(d: &Drawing, t: &Tolerance) -> GdtLayout {
let st = d.dim_style(&t.style).cloned().unwrap_or_default();
let dt = crate::dim_text(d, &st);
t.layout(d, &|s, h| cadcraft_fonts::text_width(&dt.font, s, h, dt.width_factor))
}
/// Points of a frame primitive, tessellated at `tol` (a fill's triangle as is).
pub fn gdt_points(p: &GdtPrim, tol: f64) -> Vec<Vec2> {
let mut pts = Vec::new();
match p {
GdtPrim::Line(v) => pts.clone_from(v),
GdtPrim::Circle { center, radius } => GCircle::new(*center, *radius).tessellate(tol, &mut pts),
GdtPrim::Arc { center, radius, start, end } => {
Segment::Arc { arc: GArc::new(*center, *radius, *start, *end), ccw: true }.tessellate(tol, &mut pts)
}
GdtPrim::Fill(t) => pts.extend_from_slice(t),
}
pts
}
pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, t: &Tolerance, lw: f32, tol: f64) {
let d = ctx.d;
let st = d.dim_style(&t.style).cloned().unwrap_or_default();
let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7));
let own = match e.common.color {
Color::ByLayer => layer_color,
Color::ByBlock => ctx.block_color,
c => c,
};
let frame_rgb: Ink = ink(st.dim_line_color, layer_color, own);
let text_rgb: Ink = ink(st.text_color, layer_color, own);
let g = tolerance_in(d, t);
for p in &g.frame {
b.polyline(ctx, frame_rgb, lw, &gdt_points(p, tol));
}
if !b.opts.text {
return;
}
for p in &g.symbols {
let pts = gdt_points(p, tol);
if matches!(p, GdtPrim::Fill(_)) {
b.tris(ctx, text_rgb, &pts);
} else {
b.polyline(ctx, text_rgb, lw, &pts);
}
}
let dt = crate::dim_text(d, &st);
for tx in &g.texts {
let p = cadcraft_fonts::TextParams {
rotation: tx.rotation,
width_factor: dt.width_factor,
oblique: dt.oblique,
..cadcraft_fonts::TextParams::new(tx.pos, tx.height)
};
let (sh, _) = cadcraft_fonts::place(&dt.font, &tx.value, &p);
b.shaped(ctx, text_rgb, lw, &sh);
}
}
+1
View File
@@ -999,3 +999,4 @@
Export... Експорт...
Batch Publish... Пакетна публікація...
Drawing Recovery Manager... Диспетчер відновлення креслень...
Tolerance... Допуск...
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
999
1000
1001
1002
+1 -1
View File
@@ -35,7 +35,7 @@ do them first.
| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 |
| F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 |
| F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 |
| F8 | No TOLERANCE (GD&T feature control frames) | menu "Dimension > Tolerance..." uncovered | Medium (mechanical) | 4–6 |
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
| F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 |
| F10 | MLEADER: no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
| F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 |