use acadrust::entities::Tolerance; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::SnapHint; use crate::scene::text::lff; use crate::scene::view::transform; use crate::t; // ── GDT text parser ─────────────────────────────────────────────────────────── /// The font a compartment's symbols come from. /// /// The source names it in its own escapes, and it is registered under that name /// like any other font — so nothing here special-cases it. A letter in this font /// IS a symbol; it holds no Latin text. const SYMBOL_FONT: &str = "gdt"; /// The font a compartment's ordinary text comes from. const TEXT_FONT: &str = "txt"; /// One stretch of a compartment drawn in a single font. #[derive(Debug, Clone, PartialEq)] struct Run { text: String, font: &'static str, } /// A compartment: the runs it is built from, in order. type Cell = Vec; /// The characters of `cell`, whichever font each came from. For messages and /// tests — never for measuring, since the runs are in different fonts. #[cfg(test)] fn cell_text(cell: &Cell) -> String { cell.iter().map(|r| r.text.as_str()).collect() } /// Parse a tolerance text string into rows of compartments. /// /// Example: `{\Fgdt;p}%%v0.5%%vA%%vB%%v%%v` + newline + `{\Fgdt;j}%%v0.1%%vA` /// - row separator → see below; `%%v` → compartment separator within a row /// - `{\Fgdt;X}` → character X, drawn from the font that escape names /// /// The row break reaches us in four encodings and all four are live: /// a DWG carries a raw newline; the text-DXF reader rewrites the on-disk `^J` /// to a newline before we ever see it; the binary-DXF reader does not, so `^J` /// can still arrive literally; and our own DXF writer re-emits embedded /// newlines as `\P`, because a line-based format cannot carry a raw newline /// inside a string value. Splitting on `^J` alone — as this did — therefore /// never fired on the two mainstream paths, and every row collapsed into one. fn parse_gdt_rows(raw: &str) -> Vec> { let norm = raw .replace("^J", "\n") .replace("\\P", "\n") .replace("\r\n", "\n") .replace('\r', "\n"); norm.split('\n') .filter(|row| !row.trim().is_empty()) .map(|row| { let mut cells: Vec = row.split("%%v").map(|c| parse_cell(c.trim())).collect(); // Trailing empties are unused compartments, not blank boxes: a row // written with spare datum slots ends in `%%v%%v` yet draws only the // compartments it filled. Interior empties stay — there the position // itself carries meaning (datum A, _, C). while cells.last().is_some_and(|c| c.is_empty()) { cells.pop(); } cells }) .filter(|cells: &Vec| !cells.is_empty()) .collect() } /// Split a compartment into font runs, resolving `{\Fgdt;X}` switches and /// dropping every other inline format code. /// /// The escape names its font and carries the character to draw in it; both are /// kept as-is, so the symbol is looked up by the ordinary font machinery rather /// than translated into some other character first. fn parse_cell(s: &str) -> Cell { let mut runs: Cell = Vec::new(); let push = |ch: char, font: &'static str, runs: &mut Cell| match runs.last_mut() { Some(r) if r.font == font => r.text.push(ch), _ => runs.push(Run { text: ch.to_string(), font, }), }; let mut chars = s.chars().peekable(); while let Some(ch) = chars.next() { if ch != '{' { push(ch, TEXT_FONT, &mut runs); continue; } // Collect to the closing brace. let mut inner = String::new(); let mut depth = 1usize; for c in chars.by_ref() { match c { '{' => { depth += 1; inner.push(c); } '}' => { depth -= 1; if depth == 0 { break; } inner.push(c); } _ => inner.push(c), } } if let Some(sym) = symbol_font_switch(&inner) { push(sym, SYMBOL_FONT, &mut runs); } // Any other format code contributes nothing. } runs } /// The character selected by a `\F;X` switch, or `None` when the /// braces hold some other format code. /// /// The font name may carry an extension and a run of `|`-separated parameters /// (`\Fgdt.shx|b0|i0|c134|p6;j`), so match the name and skip to the `;` rather /// than demanding the bare `\Fgdt;` form — the parameterised spelling is legal /// and would otherwise drop the symbol silently. fn symbol_font_switch(inner: &str) -> Option { let rest = inner .strip_prefix("\\F") .or_else(|| inner.strip_prefix("\\f"))?; let (name, tail) = rest.split_once(';')?; let name = name.split('|').next()?.trim(); let stem = name.strip_suffix(".shx").unwrap_or(name); if !stem.eq_ignore_ascii_case("gdt") { return None; } tail.chars().next() } /// Per-entity overrides of individual dimension-style variables, carried as /// extended data under the `DSTYLE` application. /// /// An entity may keep its style yet override single variables on itself, as /// `(variable group code, value)` pairs. Reading the style but ignoring these /// draws the frame at the style's size rather than its own — which is how this /// one, overriding its text height to a fraction of the style's, came out far /// too large. /// /// Only the character height is read — it is the frame's single geometric /// input (see `tessellate_tolerance`); every other variable is left to the style. fn dstyle_overrides(tol: &Tolerance) -> Option { // The only variable the frame is built from. const DIMTXT: i16 = 140; use acadrust::xdata::XDataValue as V; // The record belongs to the shared "ACAD" application and names itself in // its FIRST STRING VALUE. There is no record called "DSTYLE" — asking for // one finds nothing and leaves every override silently unread. let rec = tol.common.extended_data.get_record("ACAD")?; let mut vals = rec.values.iter(); match vals.next() { Some(V::String(s)) if s == "DSTYLE" => {} _ => return None, } let mut txt = None; let mut pending: Option = None; for v in vals { match v { V::Integer16(code) => pending = Some(*code), V::Real(value) | V::Distance(value) => { if pending.take() == Some(DIMTXT) { txt = Some(*value); } } // Braces and anything else just delimit; a non-numeric value also // ends the pair we were waiting on. _ => pending = None, } } txt } /// The tolerance's dimension style — by handle first, then by name. /// /// The order matters and is not interchangeable: a DWG records the style's /// handle and leaves the name at its default, while a DXF records the name and /// leaves the handle empty. Matching on the name alone — the shape used /// elsewhere for entities that only ever carry one — would silently resolve /// every DWG-read tolerance to "Standard" and pick the wrong metrics. fn resolve_dim_style<'a>( tol: &Tolerance, doc: &'a acadrust::CadDocument, ) -> Option<&'a acadrust::tables::DimStyle> { if let Some(h) = tol.dimension_style_handle { if !h.is_null() { if let Some(s) = doc.dim_styles.iter().find(|s| s.handle == h) { return Some(s); } } } let name = tol.dimension_style_name.trim(); if name.is_empty() { return None; } doc.dim_styles .iter() .find(|s| s.name.eq_ignore_ascii_case(name)) } // ── Feature-control frame builder ───────────────────────────────────────────── /// The pen advance of `text` in `font` at `height`. /// /// This is where the pen LANDS, so it carries the font's letter spacing past /// the final glyph — which is what puts the gap between one run and the next. fn run_advance(text: &str, font: &str, height: f32) -> f32 { crate::entities::text_support::text_local_bounds(font, text, height, 1.0, 0.0) .map(|b| b.advance) .unwrap_or(0.0) } /// The font's letter spacing at `height`, in world units. /// /// Glyph geometry is authored against a 9-unit cap height, so a font's spacing /// scales with the character height like everything else. fn letter_spacing(font: &str, height: f32) -> f32 { crate::scene::text::font_face::Face::resolve(font).letter_spacing() * height / 9.0 } /// How wide a compartment's content actually draws. /// /// Not the pen advance: that stops one letter-space PAST the last glyph, since /// its job is to place whatever comes next. Summing run advances therefore /// over-measures by exactly one spacing — the gaps between runs are real, the /// one hanging off the end is not — and every compartment came out that much /// too wide. fn content_width(cell: &Cell, height: f32) -> f32 { let Some(last) = cell.last() else { return 0.0; }; let pen: f32 = cell .iter() .map(|r| run_advance(&r.text, r.font, height)) .sum(); (pen - letter_spacing(last.font, height)).max(0.0) } /// One text run of a feature-control frame, ready to become a `TextStroke` /// with a `GlyphRun` so the run can render as SDF glyph quads (or, when /// SDF is off, from `strokes`). `origin` is relative to the tolerance insertion /// point (already rotated); `strokes` are the glyph polylines rotated about the /// origin (no origin translation — the wire-builder adds the origin). struct TolCell { text: String, font: &'static str, origin: [f32; 2], strokes: Vec>, height: f32, rotation: f32, } /// Tessellate a Tolerance entity's feature-control frame. /// /// Returns (`box_strokes`, `cells`): /// - `box_strokes` — the outer border, row separators and column dividers as /// 2-D polylines (rotated; run-less, so they always render as strokes) /// - `cells` — one [`TolCell`] per non-empty cell, carrying its text + a /// `GlyphRun` so the cell renders as SDF text (frame stays geometry). fn tessellate_tolerance( tol: &Tolerance, doc: &acadrust::CadDocument, ) -> (Vec>, Vec) { if tol.text.is_empty() { return (vec![], vec![]); } let rows = parse_gdt_rows(&tol.text); if rows.is_empty() { return (vec![], vec![]); } // ── Metrics ────────────────────────────────────────────────────────── // Text height and gap come from the dimension style whenever one resolves. // The entity's own fields are not trustworthy: a DWG carries only the // style's handle and leaves these at their constructed defaults (0.18 / // 0.09), which are small but non-zero — so a "> 0" test happily uses them // and the frame draws about ten times too small. Falling back to them only // when no style resolves keeps DXF-read entities working. let style = resolve_dim_style(tol, doc); let scale = style .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) .unwrap_or(1.0); // The entity's own overrides win over its style; the style wins over the // entity's constructed defaults. let h = dstyle_overrides(tol) .or(style.map(|s| s.dimtxt)) .map(|v| v * scale) .unwrap_or(tol.text_height) as f32; let h = if h > 1e-6 { h } else { 2.5_f32 }; // A compartment is a PROPORTION of the character height: it spans -h..+h // about the row's centreline, so it is exactly 2h tall and the h/2 margin // falls out of that rather than being an input. // // The gap variable is deliberately NOT read. Its scope is the dimension // line's break and the rectangle around a basic dimension — a different // entity's geometry, where it genuinely does apply twice per axis. Carrying // that shape over to this frame drew it at ~4x the character height. Even // fed a self-consistent style it can only ever produce 1.5h, never 2h, so // the formula was wrong in kind, not merely mis-fed. let cell_h = 2.0 * h; let pad = h * 0.5; let min_cell_w = 2.0 * h; // Cell widths per row, not a shared column grid: a row holding one // compartment is only as wide as that compartment, so a lone datum below a // four-compartment row draws a small box rather than inheriting the row // above it. Width comes from the real pen advance — a character count times // an average width mis-measures every symbol, and counting UTF-8 bytes // (`len()`) would make each one two or three cells wide. // A compartment's runs come from different fonts, so each is measured in its // own before they are summed. let cell_width = |cell: &Cell| -> f32 { (content_width(cell, h) + 2.0 * pad).max(min_cell_w) }; let row_widths: Vec> = rows .iter() .map(|row| row.iter().map(|c| cell_width(c)).collect()) .collect(); // ── Transform helpers (local space — translation applied in tessellate.rs) ── let angle = (tol.direction.y as f32).atan2(tol.direction.x as f32); let (sa, ca) = angle.sin_cos(); // Rotate only; origin is kept as f64 and applied later with full precision. let rot = |x: f32, y: f32| -> [f32; 2] { [x * ca - y * sa, x * sa + y * ca] }; let mut box_out: Vec> = Vec::new(); let mut cells: Vec = Vec::new(); // The insertion point sits on the CENTRELINE OF THE FIRST ROW, and rows // stack downward from there: row `i` spans `-h - 2h*i` .. `+h - 2h*i`. So // row 0 straddles y = 0 and the frame grows downward only. // // Anchoring the frame's bottom-left at the insertion point instead — as // this did — offsets the whole frame upward by its own height, and by an // amount that changes with the number of rows, so a two-row frame and a // three-row one would sit at different heights from the same point. let row_bottom = |ri: usize| -h - cell_h * ri as f32; // ── Per-row border ──────────────────────────────────────────────────── // One box per row, each left-aligned at x = 0 and only as wide as its own // compartments. Adjacent rows share the edge between them, so this draws // the row separators too. for (ri, widths) in row_widths.iter().enumerate() { let rw: f32 = widths.iter().sum(); let y0 = row_bottom(ri); let y1 = y0 + cell_h; box_out.push(vec![ rot(0.0, y0), rot(rw, y0), rot(rw, y1), rot(0.0, y1), rot(0.0, y0), ]); } // ── Compartment dividers, within each row ───────────────────────────── for (ri, widths) in row_widths.iter().enumerate() { let y0 = row_bottom(ri); let y1 = y0 + cell_h; let mut x_cursor = 0.0_f32; for w in widths.iter().take(widths.len().saturating_sub(1)) { x_cursor += w; box_out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]); } } // ── Text content per cell ───────────────────────────────────────────── // Each cell becomes a TolCell: its origin is the (rotated) cell position // relative to the insertion point; its `strokes` are the glyph polylines // rotated about that origin (used only when SDF is off). The GlyphRun the // caller attaches lets the cell render as SDF text. for (ri, row) in rows.iter().enumerate() { // The compartment is 2h tall and the character h, so an h/2 margin // centres the text on the row's centreline. let row_y = row_bottom(ri) + pad; let mut cell_x = 0.0_f32; for (ci, cell) in row.iter().enumerate() { let cw = row_widths[ri][ci]; if !cell.is_empty() { // Centre the compartment's whole content, then lay its runs out // left to right — each in its own font, each advancing the pen // by what that font actually measures. let mut run_x = cell_x + (cw - content_width(cell, h)) * 0.5; for run in cell { let (text, font) = (run.text.clone(), run.font); let (local_strokes, _) = lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, font, &text); // Glyph polylines rotated about the run's origin (no origin // translation — the wire-builder adds `origin`). let strokes: Vec> = local_strokes .into_iter() .map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect()) .filter(|pl: &Vec<[f32; 2]>| !pl.is_empty()) .collect(); let advance = run_advance(&text, font, h); cells.push(TolCell { text, font, origin: rot(run_x, row_y), strokes, height: h, rotation: angle, }); run_x += advance; } } cell_x += cw; } } (box_out, cells) } // ── RenderConvertible ────────────────────────────────────────────────────────── impl RenderConvertible for Tolerance { fn to_render(&self, document: &acadrust::CadDocument) -> Option { if self.text.is_empty() { return None; } let snap_pt = glam::DVec3::new( self.insertion_point.x, self.insertion_point.y, self.insertion_point.z, ); // Build the feature-control frame in local space; origin stored as f64. let (box_strokes, cells) = tessellate_tolerance(self, document); let ins = [self.insertion_point.x, self.insertion_point.y]; // Frame geometry first (run-less → always strokes; also the anchor // group so its origin = the insertion point), then one run-group per // text cell so the cell text renders as SDF glyphs (or strokes when // SDF is off). let mut groups: Vec = Vec::with_capacity(1 + cells.len()); if !box_strokes.is_empty() { groups.push(TextStroke { strokes: box_strokes, origin: ins, color: None, fill_tris: vec![], run: None, }); } for cell in cells { groups.push(TextStroke { strokes: cell.strokes, origin: [ ins[0] + cell.origin[0] as f64, ins[1] + cell.origin[1] as f64, ], color: None, fill_tris: vec![], run: Some(GlyphRun { text: cell.text, font: cell.font.to_string(), height: cell.height, rotation: cell.rotation, width_factor: 1.0, oblique: 0.0, // `tracking` scales the font's own letter spacing, so 0 // collapses the gap between glyphs and the characters run // together. Every other text-bearing entity passes 1.0, and // so do both the stroke path (`tessellate_text_ex`) and the // width measurement above — leaving this at 0 made the cells // measure wider than the text they drew. tracking: 1.0, bold: false, }), }); } if groups.is_empty() { return None; } Some(RenderEntity { pick_tris: Vec::new(), object: RenderObject::Text(groups), snap_pts: vec![(snap_pt, SnapHint::Insertion)], tangent_geoms: vec![], key_vertices: vec![], fill_tris: vec![], }) } } // ── Grippable ───────────────────────────────────────────────────────────────── impl Grippable for Tolerance { fn grips(&self) -> Vec { vec![square_grip( 0, glam::DVec3::new( self.insertion_point.x, self.insertion_point.y, self.insertion_point.z, ), )] } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { if grip_id == 0 { match apply { GripApply::Translate(d) => { self.insertion_point.x += d.x as f64; self.insertion_point.y += d.y as f64; self.insertion_point.z += d.z as f64; } GripApply::Absolute(p) => { self.insertion_point.x = p.x as f64; self.insertion_point.y = p.y as f64; self.insertion_point.z = p.z as f64; } } } } } // ── PropertyEditable ────────────────────────────────────────────────────────── impl PropertyEditable for Tolerance { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { vec![ PropSection { title: t!("Text").into_owned(), props: vec![ ro(t!("Text style").as_ref(), "tol_text_style", String::new()), edit(t!("Text height").as_ref(), "tol_text_height", self.text_height), ], }, PropSection { title: t!("Geometry").into_owned(), props: vec![ edit(t!("Position X").as_ref(), "tol_ix", self.insertion_point.x), edit(t!("Position Y").as_ref(), "tol_iy", self.insertion_point.y), edit(t!("Position Z").as_ref(), "tol_iz", self.insertion_point.z), ], }, PropSection { title: t!("Misc").into_owned(), props: vec![ ro( t!("Dimension style").as_ref(), "tol_dim_style", if self.dimension_style_name.is_empty() { "(default)".to_string() } else { self.dimension_style_name.clone() }, ), edit(t!("Direction X").as_ref(), "tol_dir_x", self.direction.x), edit(t!("Direction Y").as_ref(), "tol_dir_y", self.direction.y), edit(t!("Direction Z").as_ref(), "tol_dir_z", self.direction.z), ], }, ] } fn apply_geom_prop(&mut self, field: &str, value: &str) { let Ok(v) = value.trim().parse::() else { return; }; match field { "tol_ix" => self.insertion_point.x = v, "tol_iy" => self.insertion_point.y = v, "tol_iz" => self.insertion_point.z = v, "tol_text_height" if v > 0.0 => self.text_height = v, "tol_dir_x" => self.direction.x = v, "tol_dir_y" => self.direction.y = v, "tol_dir_z" => self.direction.z = v, _ => {} } } } // ── Transformable ───────────────────────────────────────────────────────────── impl Transformable for Tolerance { fn apply_transform(&mut self, t: &EntityTransform) { transform::apply_standard_entity_transform(self, t, |entity, p1, p2| { transform::reflect_xy_point( &mut entity.insertion_point.x, &mut entity.insertion_point.y, p1, p2, ); }); } } #[cfg(test)] mod tests { use super::*; /// The benchmark's own string, byte for byte, in the form a DWG carries it /// (a raw newline). This one assertion is the whole reported bug: it used to /// collapse to a single row of ASCII soup. const BENCH: &str = "{\\Fgdt;r}%%v{\\Fgdt;n}tol{\\Fgdt;m}%%v{\\Fgdt;n}tol{\\Fgdt;s}%%v1{\\Fgdt;m}%%v%%v\n2{\\Fgdt;p}\nA"; #[test] fn the_benchmark_frame_parses_to_four_one_one() { let rows = parse_gdt_rows(BENCH); // Rendered as ":" per run so a wrong font fails loudly. let shown: Vec> = rows .iter() .map(|row| { row.iter() .map(|cell| { cell.iter() .map(|r| format!("{}:{}", r.font, r.text)) .collect::>() .join("|") }) .collect() }) .collect(); assert_eq!( shown, vec![ vec![ "gdt:r".to_string(), "gdt:n|txt:tol|gdt:m".to_string(), "gdt:n|txt:tol|gdt:s".to_string(), "txt:1|gdt:m".to_string(), ], vec!["txt:2|gdt:p".to_string()], vec!["txt:A".to_string()], ], "the reference draws [◎][⌀tolⓂ][⌀tolⓈ][1Ⓜ] / [2Ⓟ] / [A], each symbol \ from the font its escape names" ); } /// A row break reaches us in four encodings depending on the format and who /// wrote it. All four must land on the same frame. #[test] fn every_row_break_encoding_gives_the_same_rows() { let want = parse_gdt_rows(BENCH); assert_eq!(want.len(), 3, "guard: the baseline itself must be 3 rows"); for (label, variant) in [ ("^J", BENCH.replace('\n', "^J")), ("\\P", BENCH.replace('\n', "\\P")), ("CRLF", BENCH.replace('\n', "\r\n")), ("CR", BENCH.replace('\n', "\r")), ] { assert_eq!(parse_gdt_rows(&variant), want, "{label} parsed differently"); } } /// Spare compartments at the end of a row are unused slots, not empty boxes /// — the benchmark's first row ends `%%v%%v` yet draws four. #[test] fn trailing_empty_compartments_are_dropped_interior_ones_kept() { let shown = |raw: &str| -> Vec> { parse_gdt_rows(raw) .iter() .map(|row| row.iter().map(cell_text).collect()) .collect() }; assert_eq!(shown("a%%vb%%v%%v"), vec![vec!["a", "b"]]); assert_eq!(shown("a%%v%%vc"), vec![vec!["a", "", "c"]]); } /// The font switch legally carries an extension and parameters; the bare /// form is only the common spelling. #[test] fn a_parameterised_font_switch_still_yields_its_symbol() { assert_eq!(symbol_font_switch("\\Fgdt;j"), Some('j')); assert_eq!(symbol_font_switch("\\Fgdt.shx|b0|i0|c134|p6;j"), Some('j')); assert_eq!(symbol_font_switch("\\FGDT;j"), Some('j')); // Not the symbol font — leave it to the generic format-code stripper. assert_eq!(symbol_font_switch("\\Farial;j"), None); assert_eq!(symbol_font_switch("\\C1"), None); } /// The frame must take its size from the dimension style. A DWG carries /// only the style's handle and leaves the entity's own height at a small /// non-zero default, so trusting that field drew the frame ~10x too small. #[test] fn the_frame_is_sized_from_the_dimension_style_not_the_entity_default() { use acadrust::tables::DimStyle; let mut doc = acadrust::CadDocument::new(); let handle = acadrust::Handle::from(0x27_u64); let mut style = DimStyle::new("ISO-25"); style.handle = handle; style.dimtxt = 2.5; style.dimgap = 0.625; style.dimscale = 1.0; doc.dim_styles.add_or_replace(style); let mut tol = Tolerance::new(); tol.text = "A".into(); // What a DWG gives us: the handle set, the metrics left at defaults. tol.dimension_style_handle = Some(handle); assert!( tol.text_height < 1.0, "guard: the default height must stay small for this test to mean anything" ); let (boxes, _) = tessellate_tolerance(&tol, &doc); // 2 x dimtxt. The entity's own default (0.18) would give 0.36. let height = frame_height(&boxes); assert!( (height - 5.0).abs() < 1e-3, "frame is {height} tall; 2 x the style's 2.5 character height is 5.0" ); } /// An entity that overrides a style variable on itself wins over the style. /// The benchmark's tolerance keeps ISO-25 but overrides its text height to /// 0.42; reading the style alone drew the frame several times too large. #[test] fn an_entity_override_beats_its_dimension_style() { use acadrust::tables::DimStyle; use acadrust::xdata::XDataValue; let mut doc = acadrust::CadDocument::new(); let handle = acadrust::Handle::from(0x27_u64); let mut style = DimStyle::new("ISO-25"); style.handle = handle; style.dimtxt = 2.5; style.dimgap = 0.625; style.dimscale = 1.0; doc.dim_styles.add_or_replace(style); let mut tol = Tolerance::new(); tol.text = "A".into(); tol.dimension_style_handle = Some(handle); // Without the override: 2 x the style's 2.5 character height. let (boxes, _) = tessellate_tolerance(&tol, &doc); assert!( (frame_height(&boxes) - 5.0).abs() < 1e-3, "guard: style-sized, got {}", frame_height(&boxes) ); // The entity's own overrides, shaped exactly as the file carries them: // application "ACAD", naming itself "DSTYLE" in its first string value, // then (variable code, value) pairs. Variable 140 = text height. let mut rec = acadrust::xdata::ExtendedDataRecord::new("ACAD"); rec.add_value(XDataValue::String("DSTYLE".into())); rec.add_value(XDataValue::ControlString("{".into())); rec.add_value(XDataValue::Integer16(140)); rec.add_value(XDataValue::Real(0.42)); rec.add_value(XDataValue::ControlString("}".into())); tol.common.extended_data.add_record(rec); let (boxes, _) = tessellate_tolerance(&tol, &doc); // 2 x 0.42 — the frame follows the entity's own character height. assert!( (frame_height(&boxes) - 0.84).abs() < 1e-3, "override ignored: frame is {} tall, expected 0.84", frame_height(&boxes) ); } fn frame_height(boxes: &[Vec<[f32; 2]>]) -> f32 { let ys: Vec = boxes.iter().flatten().map(|p| p[1]).collect(); ys.iter().cloned().fold(f32::MIN, f32::max) - ys.iter().cloned().fold(f32::MAX, f32::min) } /// Rows read downward and start at the left edge. #[test] fn rows_stack_downward_and_left_align() { let doc = acadrust::CadDocument::new(); let mut tol = Tolerance::new(); tol.text = BENCH.to_string(); let (boxes, _) = tessellate_tolerance(&tol, &doc); // One closed rect per row (plus dividers); every rect starts at x = 0. let rects: Vec<&Vec<[f32; 2]>> = boxes.iter().filter(|b| b.len() == 5).collect(); assert_eq!(rects.len(), 3, "one border per row"); for r in &rects { let x0 = r.iter().map(|p| p[0]).fold(f32::MAX, f32::min); assert!(x0.abs() < 1e-3, "row border not left-aligned: x0={x0}"); } // The insertion point is the first row's centreline: row 0 straddles // y = 0 and the frame only ever grows downward from it, so the anchor // does not drift with the number of rows. let h = tol.text_height as f32; let top = rects .iter() .flat_map(|r| r.iter()) .map(|p| p[1]) .fold(f32::MIN, f32::max); let bottom = rects .iter() .flat_map(|r| r.iter()) .map(|p| p[1]) .fold(f32::MAX, f32::min); assert!( (top - h).abs() < 1e-4, "frame should reach +h ({h}) above the insertion point, reaches {top}" ); // 3 rows of 2h, the first centred on 0 -> the last bottom is -5h. assert!( (bottom - (-5.0 * h)).abs() < 1e-4, "three rows should reach -5h ({}) below, reach {bottom}", -5.0 * h ); // Row 0 (4 compartments) is the widest AND the topmost. let top = |r: &Vec<[f32; 2]>| r.iter().map(|p| p[1]).fold(f32::MIN, f32::max); let wide = |r: &Vec<[f32; 2]>| r.iter().map(|p| p[0]).fold(f32::MIN, f32::max); assert!( top(rects[0]) > top(rects[1]) && top(rects[1]) > top(rects[2]), "rows must stack downward, row 0 on top" ); assert!( wide(rects[0]) > wide(rects[1]), "the lone datum row must not inherit the four-compartment row's width" ); } /// The cell is measured with the font's normal letter spacing, so it must be /// DRAWN with it too. They diverged once — the measurement assumed normal /// spacing while the glyph run asked for none — and the characters ran /// together inside correctly-sized boxes. #[test] fn cells_are_drawn_with_the_spacing_they_were_measured_with() { let doc = acadrust::CadDocument::new(); let mut tol = Tolerance::new(); tol.text = "ABC".into(); let Some(RenderEntity { object: RenderObject::Text(groups), .. }) = tol.to_render(&doc) else { panic!("tolerance produced no text"); }; // The frame is the run-less group; every cell carries a run. let runs: Vec<_> = groups.iter().filter_map(|g| g.run.as_ref()).collect(); assert!(!runs.is_empty(), "no cell runs emitted"); for r in &runs { assert_eq!( r.tracking, 1.0, "cell text must use the font's normal letter spacing, like every \ other entity and like the width measurement" ); } } /// The symbol font is reached by NAME, through the same lookup every other /// font goes through — so a switch to it works wherever inline codes do, not /// only inside a tolerance. An MTEXT carrying `{\\Fgdt;j}`, or a text style /// literally named "gdt.shx", lands on it too, with no code of their own. #[test] fn the_symbol_font_is_reached_by_name_like_any_other() { use crate::scene::text::font_face::Face; for name in ["gdt", "GDT", "gdt.shx"] { assert!( crate::scene::text::lff::is_builtin(name), "{name:?} must resolve to the bundled symbol font" ); let face = Face::resolve(name); let Face::Lff(font) = &face else { panic!("{name:?} resolved to a system outline, not the stroke font"); }; assert_eq!( font.name.to_ascii_lowercase(), "gdt", "{name:?} landed on {}", font.name ); // Every character the escape can carry draws from it. for c in 'a'..='z' { assert!(font.glyph(c).is_some(), "{name:?} has no glyph for {c:?}"); } } } /// It holds symbols, not letters: the glyph for 'm' is the circled M, and /// must not be the letter m the text fonts draw. #[test] fn a_letter_in_the_symbol_font_is_a_symbol() { use crate::scene::text::font_face::Face; let gdt = Face::resolve(SYMBOL_FONT); let txt = Face::resolve(TEXT_FONT); let mut differ = 0; for c in 'a'..='z' { let (g, x) = (gdt.glyph(c), txt.glyph(c)); if let (Some(g), Some(x)) = (g, x) { if g.strokes.len() != x.strokes.len() || (g.advance - x.advance).abs() > 1e-3 { differ += 1; } } } assert!( differ >= 20, "only {differ} of 26 differ from the text font — the symbol font is \ drawing letters" ); } /// A compartment is measured by what it draws, not by where the pen lands. /// The pen stops one letter-space past the last glyph — that gap belongs to /// whatever comes next, and counting it made every compartment that much /// too wide. #[test] fn compartments_are_measured_by_their_ink_not_the_trailing_pen_gap() { // Cap height, so glyph units and world units line up. let h = 9.0_f32; for src in ["{\\Fgdt;r}", "{\\Fgdt;n}tol{\\Fgdt;m}", "1{\\Fgdt;m}", "A"] { let cell = parse_cell(src); let pen: f32 = cell.iter().map(|r| run_advance(&r.text, r.font, h)).sum(); let ink: f32 = cell .iter() .map(|r| { crate::entities::text_support::text_local_bounds(r.font, &r.text, h, 1.0, 0.0) .map(|b| b.ink_max[0] - b.ink_min[0]) .unwrap_or(0.0) }) .sum(); let got = content_width(&cell, h); // Exactly one trailing gap comes off — the gaps BETWEEN runs are // real spacing and must stay. let spacing = letter_spacing(cell.last().unwrap().font, h); assert!( (got - (pen - spacing)).abs() < 1e-3, "{src:?}: content {got} should be pen {pen} less one {spacing} gap" ); let expected = ink + (cell.len() as f32 - 1.0) * spacing; assert!( (got - expected).abs() < 1e-3, "{src:?}: content {got} should be ink {ink} plus {} inter-run gaps", cell.len() - 1 ); } } }