diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 296430d1..1bb2e373 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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(), } } diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index d2f05fba..b17f4767 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -162,6 +162,8 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap 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; diff --git a/crates/doc/src/gdt.rs b/crates/doc/src/gdt.rs new file mode 100644 index 00000000..45af7c7f --- /dev/null +++ b/crates/doc/src/gdt.rs @@ -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 { + 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 { + 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; + +/// 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> { + let cs: Vec = text.chars().take(MAX_FRAME_CHARS).collect(); + let at = |i: usize| cs.get(i).copied(); + let mut rows: Vec> = vec![vec![Vec::new()]]; + // Font stack: whether the `gdt` font is current, per brace level. + let mut gdt = false; + let mut stack: Vec = Vec::new(); + let push = |rows: &mut Vec>, 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, +} + +/// 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, +} + +/// One frame line as the Geometric Tolerance dialog fills it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GdtLine { + pub symbol: Option, + pub tol1: Option, + pub tol2: Option, + pub datums: Vec, +} + +/// 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, + /// 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) -> Option { + 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 = Vec::new(); + let tol = |t: &Option| -> 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), + 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, + /// The symbols (drawn with the text colour). + pub symbols: Vec, + pub texts: Vec, + 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> = parse_frame(text) + .into_iter() + .map(|r| r.into_iter().filter(|c| !compartment_is_empty(c)).collect::>()) + .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::() + 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 { + 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| { + 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); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a569ab82..01241709 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index d70fd18a..9069fa3b 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -177,7 +177,11 @@ fn candidates(d: &Drawing, kind: Kind) -> Vec { .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() diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 6d415bae..49a7e7f3 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -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))); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bb09b81c..20333359 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 24381fe6..e78eb2ed 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1256,6 +1256,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option Some(super::tolerance::explode(d, e, t)), _ => None, } } diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index bb0cd5c9..d1f01434 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -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(_) => {} } } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ea8dd569..6e03b98b 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -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 { 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(), _ => {} })?; } diff --git a/crates/engine/src/cmd/tolerance.rs b/crates/engine/src/cmd/tolerance.rs new file mode 100644 index 00000000..1a33a567 --- /dev/null +++ b/crates/engine/src/cmd/tolerance.rs @@ -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 { + 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) { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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| 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 { + 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 { + 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 +} diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index dfc7c2f2..4dd7e07f 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -93,7 +93,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option { 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)); diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 5ebc8780..3e927f16 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -15,6 +15,7 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec> { | 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; diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index f7358f47..1d1134b4 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -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()))); diff --git a/crates/engine/tests/tolerance.rs b/crates/engine/tests/tolerance.rs new file mode 100644 index 00000000..a5873560 --- /dev/null +++ b/crates/engine/tests/tolerance.rs @@ -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] })); + } + } +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 95e73ab0..766569bc 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -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(), diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index e598e71c..a98e1045 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -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(_) => {} } diff --git a/crates/io/tests/tolerance_roundtrip.rs b/crates/io/tests/tolerance_roundtrip.rs new file mode 100644 index 00000000..d004b458 --- /dev/null +++ b/crates/io/tests/tolerance_roundtrip.rs @@ -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()); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a5beddb6..48a968c6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 = l.vertices.iter().map(|v| v.xy()).collect(); stroke(b, &pts); diff --git a/crates/render/src/tolerance.rs b/crates/render/src/tolerance.rs new file mode 100644 index 00000000..afa6d7b2 --- /dev/null +++ b/crates/render/src/tolerance.rs @@ -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 { + 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); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1b234d78..b4d10e3b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -999,3 +999,4 @@ Export... Експорт... Batch Publish... Пакетна публікація... Drawing Recovery Manager... Диспетчер відновлення креслень... + Tolerance... Допуск... diff --git a/docs/gaps.md b/docs/gaps.md index a94ce7e8..fe7b383f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 |