diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 5ab0587cf..a2f0021a2 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -12,6 +12,14 @@ use cadcraft_geom::{Arc, Vec2, ccw_sweep}; use crate::units::{NumFormat, format_angle_with_separator, format_decimal, format_linear}; +mod arrowheads; +mod text; + +use arrowheads::tick; +pub use arrowheads::{ArrowGeom, Arrowhead, arrow, arrowhead}; +pub use text::{DimText, format_linear_value}; +use text::{apply_override, inspected, linear_text}; + /// Most DIMBREAK gaps drawn per dimension. const MAX_BREAKS: usize = 1000; @@ -145,183 +153,6 @@ impl DimGeometry { } } -/// The arrowheads drawn in code (DIMBLK names; our own geometry). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Arrowhead { - ClosedFilled, - ClosedBlank, - Closed, - Dot, - DotSmall, - DotBlank, - Oblique, - ArchTick, - Open, - Open90, - Open30, - Origin, - Origin2, - Small, - BoxFilled, - BoxBlank, - DatumFilled, - DatumBlank, - Integral, - None, -} - -impl Arrowhead { - /// Parse a DIMBLK value ("" / "_DOT" / "Architectural tick" / "closed blank"…). - pub fn parse(name: &str) -> Arrowhead { - let n: String = name.trim().trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); - match n.as_str() { - "" | "closedfilled" | "filled" => Arrowhead::ClosedFilled, - "closedblank" | "blank" => Arrowhead::ClosedBlank, - "closed" => Arrowhead::Closed, - "dot" => Arrowhead::Dot, - "dotsmall" => Arrowhead::DotSmall, - "dotblank" => Arrowhead::DotBlank, - "oblique" => Arrowhead::Oblique, - "archtick" | "architecturaltick" | "tick" => Arrowhead::ArchTick, - "open" => Arrowhead::Open, - "open90" | "rightangle" => Arrowhead::Open90, - "open30" => Arrowhead::Open30, - "origin" | "originindicator" => Arrowhead::Origin, - "origin2" | "originindicator2" => Arrowhead::Origin2, - "small" => Arrowhead::Small, - "boxfilled" => Arrowhead::BoxFilled, - "boxblank" | "box" => Arrowhead::BoxBlank, - "datumfilled" | "datumtrianglefilled" => Arrowhead::DatumFilled, - "datumblank" | "datumtriangle" => Arrowhead::DatumBlank, - "integral" => Arrowhead::Integral, - "none" => Arrowhead::None, - _ => Arrowhead::ClosedFilled, - } - } - /// Ticks and obliques: the dimension line runs through the tip and extends by DIMDLE. - pub fn is_tick(self) -> bool { - matches!(self, Arrowhead::Oblique | Arrowhead::ArchTick | Arrowhead::Integral | Arrowhead::None) - } - /// How far back from the tip the dimension line stops (blank heads are not crossed). - pub fn inset(self, size: f64) -> f64 { - match self { - Arrowhead::ClosedBlank | Arrowhead::DatumBlank => size, - Arrowhead::DotBlank => size / 4.0, - Arrowhead::Origin | Arrowhead::Origin2 => size / 2.0, - Arrowhead::BoxBlank => size / 4.0, - _ => 0.0, - } - } -} - -/// Arrowhead geometry: open polylines and filled triangles. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ArrowGeom { - pub lines: Vec>, - pub tris: Vec, -} - -fn circle_pts(c: Vec2, r: f64) -> Vec { - (0..=24).map(|i| c + Vec2::from_angle(f64::from(i) * cadcraft_geom::TAU / 24.0) * r).collect() -} - -fn disc(c: Vec2, r: f64) -> Vec { - let mut t = Vec::with_capacity(72); - for i in 0..24 { - let a0 = f64::from(i) * cadcraft_geom::TAU / 24.0; - let a1 = f64::from(i + 1) * cadcraft_geom::TAU / 24.0; - t.extend_from_slice(&[c, c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r]); - } - t -} - -/// An arrowhead at `tip` pointing along `dir` (unit), `size` = DIMASZ × scale. -pub fn arrowhead(kind: Arrowhead, tip: Vec2, dir: Vec2, size: f64) -> ArrowGeom { - let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir }; - let size = if size.is_finite() { size.abs() } else { 0.0 }; - let n = dir.perp(); - let wings = |half_angle: f64, len: f64| { - let back = -dir; - (tip + back.rotate(half_angle) * len, tip + back.rotate(-half_angle) * len) - }; - let mut g = ArrowGeom::default(); - match kind { - Arrowhead::ClosedFilled => g.tris = arrow(tip, dir, size), - Arrowhead::Small => g.tris = arrow(tip, dir, size / 2.0), - Arrowhead::ClosedBlank | Arrowhead::Closed => { - let back = tip - dir * size; - let w = n * (size / 6.0); - g.lines.push(vec![tip, back + w, back - w, tip]); - if kind == Arrowhead::Closed { - g.lines.push(vec![back, tip]); - } - } - Arrowhead::Dot => g.tris = disc(tip, size / 4.0), - Arrowhead::DotSmall => g.tris = disc(tip, size / 8.0), - Arrowhead::DotBlank => g.lines.push(circle_pts(tip, size / 4.0)), - Arrowhead::Origin => g.lines.push(circle_pts(tip, size / 2.0)), - Arrowhead::Origin2 => { - g.lines.push(circle_pts(tip, size / 2.0)); - g.lines.push(circle_pts(tip, size / 4.0)); - } - Arrowhead::Oblique => { - let t = canonical(dir + n).normalized() * (size / 2.0); - g.lines.push(vec![tip - t, tip + t]); - } - Arrowhead::ArchTick => { - // A heavy 45° stroke: a thin filled parallelogram. - let t = canonical(dir + n).normalized() * (size / 2.0); - let w = t.perp().normalized() * (size / 16.0); - let (a, b) = (tip - t, tip + t); - g.tris = vec![a - w, b - w, b + w, a - w, b + w, a + w]; - } - Arrowhead::Integral => { - // A small integral-sign wave through the tip. - let t = canonical(dir + n).normalized(); - let pts = (-8..=8) - .map(|i| { - let f = f64::from(i) / 8.0; - tip + t * (f * size / 2.0) + t.perp() * ((f * std::f64::consts::PI).sin() * size * 0.12) - }) - .collect(); - g.lines.push(pts); - } - Arrowhead::Open => { - let (a, b) = wings((1.0f64 / 6.0).atan(), size); - g.lines.push(vec![a, tip, b]); - } - Arrowhead::Open90 => { - let (a, b) = wings(std::f64::consts::FRAC_PI_4, size / std::f64::consts::SQRT_2); - g.lines.push(vec![a, tip, b]); - } - Arrowhead::Open30 => { - let (a, b) = wings(15f64.to_radians(), size); - g.lines.push(vec![a, tip, b]); - } - Arrowhead::BoxFilled | Arrowhead::BoxBlank => { - let h = size / 4.0; - let (u, v) = (dir * h, n * h); - let q = [tip - u - v, tip + u - v, tip + u + v, tip - u + v]; - if kind == Arrowhead::BoxFilled { - g.tris = vec![q[0], q[1], q[2], q[0], q[2], q[3]]; - } else { - g.lines.push(vec![q[0], q[1], q[2], q[3], q[0]]); - } - } - Arrowhead::DatumFilled | Arrowhead::DatumBlank => { - let apex = tip - dir * size; - let w = n * (size / 2.0); - if kind == Arrowhead::DatumFilled { - g.tris = vec![tip + w, tip - w, apex]; - } else { - g.lines.push(vec![tip + w, tip - w, apex, tip + w]); - } - } - Arrowhead::None => {} - } - g -} - /// The pieces of a polyline outside the circle (`c`, `r`). fn cut_polyline(l: &[Vec2], c: Vec2, r: f64) -> Vec> { let mut out: Vec> = Vec::new(); @@ -360,146 +191,6 @@ fn cut_polyline(l: &[Vec2], c: Vec2, r: f64) -> Vec> { out } -/// A closed filled arrowhead at `tip` pointing along `dir` (unit), as one triangle. -pub fn arrow(tip: Vec2, dir: Vec2, size: f64) -> Vec { - let back = tip - dir * size; - let n = dir.perp() * (size / 6.0); - vec![tip, back + n, back - n] -} - -/// Ticks lean the same way at both ends of a dimension line whichever way they point. -fn canonical(v: Vec2) -> Vec2 { - if v.x < -1e-12 || (v.x.abs() <= 1e-12 && v.y < 0.0) { -v } else { v } -} - -/// A DIMTSZ tick: an oblique stroke centred on `at`. -fn tick(at: Vec2, dir: Vec2, half: f64) -> Vec { - let d = canonical(dir); - let t = (d + d.perp()).normalized() * half; - vec![at - t, at + t] -} - -/// Format a linear measurement per the style (plain text). -pub fn format_linear_value(v: f64, st: &DimStyle) -> String { - format_linear(v * st.linear_factor, &NumFormat::from_style(st)).0 -} - -/// The text of a linear measurement: (plain, MTEXT) with DIMPOST, tolerances/limits and -/// alternate units, then the user's override text (`<>` = the measurement). DIMRND rounds the -/// measurement (and so the limits) only: tolerances are never rounded and alternate units -/// round by DIMALTRND. -fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String { - let v = meas * st.linear_factor; - let nf = NumFormat::from_style(st); - let main = if st.limits { - let up = format_linear(v + st.tol_plus, &nf).1; - let lo = format_linear(v - st.tol_minus, &nf).1; - format!("{{\\H{};\\S{up}^{lo};}}", tol_height_code(st)) - } else { - format!("{prefix}{}", format_linear(v, &nf).1) - }; - let mut s = post(&st.post, &main); - if st.tolerance && !st.limits { - let tf = NumFormat { decimals: st.tol_decimals, zin: st.tol_zero_suppression, round: 0.0, ..nf.clone() }; - // DIMPOST's suffix (what follows `<>`, or all of it) also goes on the tolerance values. - let suffix = st.post.split_once("<>").map_or(st.post.as_str(), |(_, after)| after); - if (st.tol_plus - st.tol_minus).abs() < 1e-12 { - s += &format!("%%p{}{suffix}", format_linear(st.tol_plus, &tf).1); - } else { - let plus = format_linear(st.tol_plus, &tf).1 + suffix; - let minus = format_linear(st.tol_minus, &tf).1 + suffix; - let sign = |x: &str, pos: char| if x.trim_start_matches(['0', '.', ',']).is_empty() { x.to_string() } else { format!("{pos}{x}") }; - let (plus, minus) = - (sign(&plus, '+'), if st.tol_minus < 0.0 { format!("+{}", minus.trim_start_matches('-')) } else { sign(&minus, '-') }); - // DIMTOLJ: bottom / middle / top (MTEXT `\A`; middle is the default). - let tolj = if st.tol_just == 1 { String::new() } else { format!("\\A{};", st.tol_just.min(2)) }; - s += &format!("{{\\H{};{tolj}\\S{plus}^{minus};}}", tol_height_code(st)); - } - } - if st.alt { - // DIMALTU 6 and 7 are architectural and fractional without stacking, 8 is decimal. - let (unit, frac) = match st.alt_unit { - u @ 1..=5 => (u, nf.frac), - 6 => (4, 2), - 7 => (5, 2), - _ => (2, nf.frac), - }; - let af = NumFormat { unit, decimals: st.alt_decimals, zin: st.alt_zero_suppression, round: st.alt_round, frac, ..nf }; - let alt = format_linear(v * st.alt_factor, &af).1; - s += &format!(" [{}]", post(&st.alt_post, &alt)); - } - inspected(apply_override(&s, d), d) -} - -/// DIMINSPECT: the label and the rate either side of the value, two spaces apart. -fn inspected(value: String, d: &Dimension) -> String { - match &d.marks.inspect { - Some(ins) => [ins.label.as_str(), value.as_str(), ins.rate.as_str()].iter().filter(|p| !p.is_empty()).copied().collect::>().join(" "), - None => value, - } -} - -/// `\H` code for stacked tolerance parts (DIMTFAC relative to the 70% stack scale). -fn tol_height_code(st: &DimStyle) -> String { - let f = if st.tol_scale.is_finite() && st.tol_scale > 0.0 { st.tol_scale } else { 1.0 }; - format!("{}x", format_decimal(f / 0.7, 4, 8, ".")) -} - -/// Apply measurement placeholders or append a dimension suffix, bounding the resulting UTF-8 text. -fn post(template: &str, meas: &str) -> String { - if template.contains("<>") { - bounded_replace(template, meas) - } else { - let mut out = String::new(); - append_dim_text(&mut out, meas); - append_dim_text(&mut out, template); - out - } -} - -/// Apply dimension text overrides: empty uses the base, whitespace suppresses text, and other text substitutes it. -fn apply_override(base: &str, d: &Dimension) -> String { - if d.text.is_empty() { - base.to_string() - } else if d.text.trim().is_empty() { - String::new() - } else { - bounded_replace(&d.text, base) - } -} - -/// Largest dimension text produced by substituting the measurement into style/override text. Two -/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers -/// (CWE-789); past this the output is truncated rather than allocated unbounded. -const MAX_DIM_TEXT: usize = 64 * 1024; - -/// Replace every `<>` in `template` with `value`, with the expanded length checked against -/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory. -fn bounded_replace(template: &str, value: &str) -> String { - let mut out = String::new(); - let mut rest = template; - while let Some(i) = rest.find("<>") { - if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) { - return out; - } - rest = &rest[i + 2..]; - } - append_dim_text(&mut out, rest); - out -} - -// Returns false when truncation consumed the remaining UTF-8-safe space. -/// Append within the remaining dimension-text byte allowance without splitting a UTF-8 character. -/// Return false if any input text had to be truncated. -fn append_dim_text(out: &mut String, text: &str) -> bool { - let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); - while !text.is_char_boundary(end) { - end -= 1; - } - out.push_str(&text[..end]); - end == text.len() -} - fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option> { let v = foot - origin; let len = v.len(); @@ -525,16 +216,6 @@ fn readable(a: f64) -> f64 { a } -/// Font and fixed height of the dimension text (from DIMTXSTY's text style). -#[derive(Clone, Debug, Default)] -pub struct DimText { - pub font: TextFont, - /// Fixed text height of the style (0 = use DIMTXT × scale). - pub fixed_height: f64, - pub width_factor: f64, - pub oblique: f64, -} - /// Generate the geometry of a dimension with the stroke font. pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeometry { dimension_geometry_with(d, st, dimscale, &DimText { width_factor: 1.0, ..Default::default() }) @@ -1080,23 +761,3 @@ fn decode_value(s: &str) -> String { #[cfg(test)] mod tests; - -#[cfg(test)] -mod text_budget_tests { - use super::*; - /// A dimension suffix without placeholders still obeys the total text limit. - #[test] - fn bounds_post_without_markers() { - let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); - assert_eq!(text.len(), MAX_DIM_TEXT); - assert!(text.starts_with("12x")); - } - /// Oversized substituted text retains its prefix and ends at a valid UTF-8 boundary. - #[test] - fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { - let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); - assert!(text.starts_with("prefix é")); - assert!(!text.contains("<>")); - assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1); - } -} diff --git a/crates/render/src/dim/arrowheads.rs b/crates/render/src/dim/arrowheads.rs new file mode 100644 index 000000000..5940a27e9 --- /dev/null +++ b/crates/render/src/dim/arrowheads.rs @@ -0,0 +1,197 @@ +use super::*; + +/// The arrowheads drawn in code (DIMBLK names; our own geometry). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arrowhead { + ClosedFilled, + ClosedBlank, + Closed, + Dot, + DotSmall, + DotBlank, + Oblique, + ArchTick, + Open, + Open90, + Open30, + Origin, + Origin2, + Small, + BoxFilled, + BoxBlank, + DatumFilled, + DatumBlank, + Integral, + None, +} + +impl Arrowhead { + /// Parse a DIMBLK value ("" / "_DOT" / "Architectural tick" / "closed blank"…). + pub fn parse(name: &str) -> Arrowhead { + let n: String = name.trim().trim_start_matches('_').chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); + match n.as_str() { + "" | "closedfilled" | "filled" => Arrowhead::ClosedFilled, + "closedblank" | "blank" => Arrowhead::ClosedBlank, + "closed" => Arrowhead::Closed, + "dot" => Arrowhead::Dot, + "dotsmall" => Arrowhead::DotSmall, + "dotblank" => Arrowhead::DotBlank, + "oblique" => Arrowhead::Oblique, + "archtick" | "architecturaltick" | "tick" => Arrowhead::ArchTick, + "open" => Arrowhead::Open, + "open90" | "rightangle" => Arrowhead::Open90, + "open30" => Arrowhead::Open30, + "origin" | "originindicator" => Arrowhead::Origin, + "origin2" | "originindicator2" => Arrowhead::Origin2, + "small" => Arrowhead::Small, + "boxfilled" => Arrowhead::BoxFilled, + "boxblank" | "box" => Arrowhead::BoxBlank, + "datumfilled" | "datumtrianglefilled" => Arrowhead::DatumFilled, + "datumblank" | "datumtriangle" => Arrowhead::DatumBlank, + "integral" => Arrowhead::Integral, + "none" => Arrowhead::None, + _ => Arrowhead::ClosedFilled, + } + } + /// Ticks and obliques: the dimension line runs through the tip and extends by DIMDLE. + pub fn is_tick(self) -> bool { + matches!(self, Arrowhead::Oblique | Arrowhead::ArchTick | Arrowhead::Integral | Arrowhead::None) + } + /// How far back from the tip the dimension line stops (blank heads are not crossed). + pub fn inset(self, size: f64) -> f64 { + match self { + Arrowhead::ClosedBlank | Arrowhead::DatumBlank => size, + Arrowhead::DotBlank => size / 4.0, + Arrowhead::Origin | Arrowhead::Origin2 => size / 2.0, + Arrowhead::BoxBlank => size / 4.0, + _ => 0.0, + } + } +} + +/// Arrowhead geometry: open polylines and filled triangles. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ArrowGeom { + pub lines: Vec>, + pub tris: Vec, +} + +fn circle_pts(c: Vec2, r: f64) -> Vec { + (0..=24).map(|i| c + Vec2::from_angle(f64::from(i) * cadcraft_geom::TAU / 24.0) * r).collect() +} + +fn disc(c: Vec2, r: f64) -> Vec { + let mut t = Vec::with_capacity(72); + for i in 0..24 { + let a0 = f64::from(i) * cadcraft_geom::TAU / 24.0; + let a1 = f64::from(i + 1) * cadcraft_geom::TAU / 24.0; + t.extend_from_slice(&[c, c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r]); + } + t +} + +/// An arrowhead at `tip` pointing along `dir` (unit), `size` = DIMASZ × scale. +pub fn arrowhead(kind: Arrowhead, tip: Vec2, dir: Vec2, size: f64) -> ArrowGeom { + let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir }; + let size = if size.is_finite() { size.abs() } else { 0.0 }; + let n = dir.perp(); + let wings = |half_angle: f64, len: f64| { + let back = -dir; + (tip + back.rotate(half_angle) * len, tip + back.rotate(-half_angle) * len) + }; + let mut g = ArrowGeom::default(); + match kind { + Arrowhead::ClosedFilled => g.tris = arrow(tip, dir, size), + Arrowhead::Small => g.tris = arrow(tip, dir, size / 2.0), + Arrowhead::ClosedBlank | Arrowhead::Closed => { + let back = tip - dir * size; + let w = n * (size / 6.0); + g.lines.push(vec![tip, back + w, back - w, tip]); + if kind == Arrowhead::Closed { + g.lines.push(vec![back, tip]); + } + } + Arrowhead::Dot => g.tris = disc(tip, size / 4.0), + Arrowhead::DotSmall => g.tris = disc(tip, size / 8.0), + Arrowhead::DotBlank => g.lines.push(circle_pts(tip, size / 4.0)), + Arrowhead::Origin => g.lines.push(circle_pts(tip, size / 2.0)), + Arrowhead::Origin2 => { + g.lines.push(circle_pts(tip, size / 2.0)); + g.lines.push(circle_pts(tip, size / 4.0)); + } + Arrowhead::Oblique => { + let t = canonical(dir + n).normalized() * (size / 2.0); + g.lines.push(vec![tip - t, tip + t]); + } + Arrowhead::ArchTick => { + // A heavy 45° stroke: a thin filled parallelogram. + let t = canonical(dir + n).normalized() * (size / 2.0); + let w = t.perp().normalized() * (size / 16.0); + let (a, b) = (tip - t, tip + t); + g.tris = vec![a - w, b - w, b + w, a - w, b + w, a + w]; + } + Arrowhead::Integral => { + // A small integral-sign wave through the tip. + let t = canonical(dir + n).normalized(); + let pts = (-8..=8) + .map(|i| { + let f = f64::from(i) / 8.0; + tip + t * (f * size / 2.0) + t.perp() * ((f * std::f64::consts::PI).sin() * size * 0.12) + }) + .collect(); + g.lines.push(pts); + } + Arrowhead::Open => { + let (a, b) = wings((1.0f64 / 6.0).atan(), size); + g.lines.push(vec![a, tip, b]); + } + Arrowhead::Open90 => { + let (a, b) = wings(std::f64::consts::FRAC_PI_4, size / std::f64::consts::SQRT_2); + g.lines.push(vec![a, tip, b]); + } + Arrowhead::Open30 => { + let (a, b) = wings(15f64.to_radians(), size); + g.lines.push(vec![a, tip, b]); + } + Arrowhead::BoxFilled | Arrowhead::BoxBlank => { + let h = size / 4.0; + let (u, v) = (dir * h, n * h); + let q = [tip - u - v, tip + u - v, tip + u + v, tip - u + v]; + if kind == Arrowhead::BoxFilled { + g.tris = vec![q[0], q[1], q[2], q[0], q[2], q[3]]; + } else { + g.lines.push(vec![q[0], q[1], q[2], q[3], q[0]]); + } + } + Arrowhead::DatumFilled | Arrowhead::DatumBlank => { + let apex = tip - dir * size; + let w = n * (size / 2.0); + if kind == Arrowhead::DatumFilled { + g.tris = vec![tip + w, tip - w, apex]; + } else { + g.lines.push(vec![tip + w, tip - w, apex, tip + w]); + } + } + Arrowhead::None => {} + } + g +} + +/// A closed filled arrowhead at `tip` pointing along `dir` (unit), as one triangle. +pub fn arrow(tip: Vec2, dir: Vec2, size: f64) -> Vec { + let back = tip - dir * size; + let n = dir.perp() * (size / 6.0); + vec![tip, back + n, back - n] +} + +/// Ticks lean the same way at both ends of a dimension line whichever way they point. +fn canonical(v: Vec2) -> Vec2 { + if v.x < -1e-12 || (v.x.abs() <= 1e-12 && v.y < 0.0) { -v } else { v } +} + +/// A DIMTSZ tick: an oblique stroke centred on `at`. +pub(super) fn tick(at: Vec2, dir: Vec2, half: f64) -> Vec { + let d = canonical(dir); + let t = (d + d.perp()).normalized() * half; + vec![at - t, at + t] +} diff --git a/crates/render/src/dim/text.rs b/crates/render/src/dim/text.rs new file mode 100644 index 000000000..fb3d6eda7 --- /dev/null +++ b/crates/render/src/dim/text.rs @@ -0,0 +1,152 @@ +use super::*; + +/// Format a linear measurement per the style (plain text). +pub fn format_linear_value(v: f64, st: &DimStyle) -> String { + format_linear(v * st.linear_factor, &NumFormat::from_style(st)).0 +} + +/// The text of a linear measurement: (plain, MTEXT) with DIMPOST, tolerances/limits and +/// alternate units, then the user's override text (`<>` = the measurement). DIMRND rounds the +/// measurement (and so the limits) only: tolerances are never rounded and alternate units +/// round by DIMALTRND. +pub(super) fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String { + let v = meas * st.linear_factor; + let nf = NumFormat::from_style(st); + let main = if st.limits { + let up = format_linear(v + st.tol_plus, &nf).1; + let lo = format_linear(v - st.tol_minus, &nf).1; + format!("{{\\H{};\\S{up}^{lo};}}", tol_height_code(st)) + } else { + format!("{prefix}{}", format_linear(v, &nf).1) + }; + let mut s = post(&st.post, &main); + if st.tolerance && !st.limits { + let tf = NumFormat { decimals: st.tol_decimals, zin: st.tol_zero_suppression, round: 0.0, ..nf.clone() }; + // DIMPOST's suffix (what follows `<>`, or all of it) also goes on the tolerance values. + let suffix = st.post.split_once("<>").map_or(st.post.as_str(), |(_, after)| after); + if (st.tol_plus - st.tol_minus).abs() < 1e-12 { + s += &format!("%%p{}{suffix}", format_linear(st.tol_plus, &tf).1); + } else { + let plus = format_linear(st.tol_plus, &tf).1 + suffix; + let minus = format_linear(st.tol_minus, &tf).1 + suffix; + let sign = |x: &str, pos: char| if x.trim_start_matches(['0', '.', ',']).is_empty() { x.to_string() } else { format!("{pos}{x}") }; + let (plus, minus) = + (sign(&plus, '+'), if st.tol_minus < 0.0 { format!("+{}", minus.trim_start_matches('-')) } else { sign(&minus, '-') }); + // DIMTOLJ: bottom / middle / top (MTEXT `\A`; middle is the default). + let tolj = if st.tol_just == 1 { String::new() } else { format!("\\A{};", st.tol_just.min(2)) }; + s += &format!("{{\\H{};{tolj}\\S{plus}^{minus};}}", tol_height_code(st)); + } + } + if st.alt { + // DIMALTU 6 and 7 are architectural and fractional without stacking, 8 is decimal. + let (unit, frac) = match st.alt_unit { + u @ 1..=5 => (u, nf.frac), + 6 => (4, 2), + 7 => (5, 2), + _ => (2, nf.frac), + }; + let af = NumFormat { unit, decimals: st.alt_decimals, zin: st.alt_zero_suppression, round: st.alt_round, frac, ..nf }; + let alt = format_linear(v * st.alt_factor, &af).1; + s += &format!(" [{}]", post(&st.alt_post, &alt)); + } + inspected(apply_override(&s, d), d) +} + +/// DIMINSPECT: the label and the rate either side of the value, two spaces apart. +pub(super) fn inspected(value: String, d: &Dimension) -> String { + match &d.marks.inspect { + Some(ins) => [ins.label.as_str(), value.as_str(), ins.rate.as_str()].iter().filter(|p| !p.is_empty()).copied().collect::>().join(" "), + None => value, + } +} + +/// `\H` code for stacked tolerance parts (DIMTFAC relative to the 70% stack scale). +fn tol_height_code(st: &DimStyle) -> String { + let f = if st.tol_scale.is_finite() && st.tol_scale > 0.0 { st.tol_scale } else { 1.0 }; + format!("{}x", format_decimal(f / 0.7, 4, 8, ".")) +} + +/// Apply measurement placeholders or append a dimension suffix, bounding the resulting UTF-8 text. +fn post(template: &str, meas: &str) -> String { + if template.contains("<>") { + bounded_replace(template, meas) + } else { + let mut out = String::new(); + append_dim_text(&mut out, meas); + append_dim_text(&mut out, template); + out + } +} + +/// Apply dimension text overrides: empty uses the base, whitespace suppresses text, and other text substitutes it. +pub(super) fn apply_override(base: &str, d: &Dimension) -> String { + if d.text.is_empty() { + base.to_string() + } else if d.text.trim().is_empty() { + String::new() + } else { + bounded_replace(&d.text, base) + } +} + +/// Largest dimension text produced by substituting the measurement into style/override text. Two +/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers +/// (CWE-789); past this the output is truncated rather than allocated unbounded. +const MAX_DIM_TEXT: usize = 64 * 1024; + +/// Replace every `<>` in `template` with `value`, with the expanded length checked against +/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory. +fn bounded_replace(template: &str, value: &str) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find("<>") { + if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) { + return out; + } + rest = &rest[i + 2..]; + } + append_dim_text(&mut out, rest); + out +} + +// Returns false when truncation consumed the remaining UTF-8-safe space. +/// Append within the remaining dimension-text byte allowance without splitting a UTF-8 character. +/// Return false if any input text had to be truncated. +fn append_dim_text(out: &mut String, text: &str) -> bool { + let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); + while !text.is_char_boundary(end) { + end -= 1; + } + out.push_str(&text[..end]); + end == text.len() +} + +/// Font and fixed height of the dimension text (from DIMTXSTY's text style). +#[derive(Clone, Debug, Default)] +pub struct DimText { + pub font: TextFont, + /// Fixed text height of the style (0 = use DIMTXT × scale). + pub fixed_height: f64, + pub width_factor: f64, + pub oblique: f64, +} + +#[cfg(test)] +mod text_budget_tests { + use super::*; + /// A dimension suffix without placeholders still obeys the total text limit. + #[test] + fn bounds_post_without_markers() { + let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); + assert_eq!(text.len(), MAX_DIM_TEXT); + assert!(text.starts_with("12x")); + } + /// Oversized substituted text retains its prefix and ends at a valid UTF-8 boundary. + #[test] + fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { + let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); + assert!(text.starts_with("prefix é")); + assert!(!text.contains("<>")); + assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1); + } +}