diff --git a/assets/fonts/gdt.lff b/assets/fonts/gdt.lff new file mode 100644 index 000000000..d0498017d --- /dev/null +++ b/assets/fonts/gdt.lff @@ -0,0 +1,143 @@ +# Format: LibreCAD Font 1 +# Creator: converted from the QCAD 3 'gdt' CXF font +# Version: 1 +# Name: gdt +# Encoding: UTF-8 +# LetterSpacing: 3 +# WordSpacing: 6.75 +# LineSpacingFactor: 1 +# Author: Andrew Mustun (QCAD) +# License: GPL v3 + +[0061] a +0,0;13.5,0 +0,0;13.5,7.7 + +[0062] b +0,-2;18,-2 +9,-2;9,11 + +[0063] c +0,0;5,9 +5,9;18.5,9 +18.5,9;13.5,0 +13.5,0;0,0 + +[0064] d +0,0;18,0 +18,0;0,0,A1 + +[0065] e +14,4.5;0,4.5,A1;14,4.5,A1 + +[0066] f +0,-2.35;7.7491,11.0718 +5.45,-2.35;13.1991,11.0718 + +[0067] g +0,-2.35;7.7491,11.0718 +10.3918,-2.35;18.1409,11.0718 +13.651015,4.5;4.651015,4.5,A1;13.651015,4.5,A1 + +[0068] h +0,-2;8,6 +10.071,3.9289;5.929,8.0711 +13,11;5.929,8.0711 +13,11;10.071,3.9289 + +[0069] i +4,0;9.4,0 +4,9;9.4,9 +0,4.5;13.4,4.5 + +[006A] j +0,4.5;13.5,4.5 +6.75,11.25;6.75,-2.25 +11.3,4.5;2.2,4.5,A1;11.3,4.5,A1 + +[006B] k +18,0;0,0,A1 + +[006C] l +14,4.5;0,4.5,A1;14,4.5,A1 +5,8;5,1 +5,1;9,1 + +[006D] m +14,4.5;0,4.5,A1;14,4.5,A1 +4,1;4,8 +4,8;7,1 +7,1;10,8 +10,8;10,1 + +[006E] n +9,4.5;0,4.5,A1;9,4.5,A1 +0.625,-2.2117;8.375,11.2117 + +[006F] o +0,9;9,9 +9,9;9,0 +9,0;0,0 +0,0;0,9 + +[0070] p +14,4.5;0,4.5,A1;14,4.5,A1 +4.5,1;4.5,8 +4.5,8;7.75,8 +4.5,4.5;7.75,4.5 +7.75,4.5;7.75,8,A1 + +[0071] q +1.5,9;1.5,0 +1.5,0;4.5,0 +0,6;3,6,A-1 +0,3;3,3,A1 +0,6;0,3 + +[0072] r +14,4.5;0,4.5,A1;14,4.5,A1 +11.5,4.5;2.5,4.5,A1;11.5,4.5,A1 + +[0073] s +14,4.5;0,4.5,A1;14,4.5,A1 +8.9446,7.4412;6.6109,8,A0.118;5.9047,5.0584,A0.7883;8.0957,3.9416;7.3887,1,A-0.7882;5.0574,1.5575,A-0.1179 + +[0074] t +0,-2;8,6 +10,-2;18,6 +10.071,3.9289;5.929,8.0711 +20.071,3.9289;15.929,8.0711 +13,11;5.929,8.0711 +23,11;15.929,8.0711 +13,11;10.071,3.9289 +23,11;20.071,3.9289 +0,-2;10,-2 + +[0075] u +0,4.5;17,4.5 + +[0076] v +0,9;0,0 +0,0;18,0 +18,0;18,9 + +[0077] w +0,9;9,0 +9,0;18,9 + +[0078] x +0,9;9,9 +4.5,9;4.5,0 +4.5,0;7.5,5.1962 +4.5,0;1.5,5.1962 + +[0079] y +0,4.5;42.5,4.5 +4.5,13.4;38,4.5 +4.5,-4.4;38,4.5 +4.5,13.4;4.5,-4.4 + +[007A] z +0,0;34,0 +0,0;0,9 +0,9;34,0 diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index 2b9a7caf0..cf529026b 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -11,105 +11,256 @@ use crate::scene::view::transform; // ── GDT text parser ─────────────────────────────────────────────────────────── -/// Parse a DXF tolerance text string into rows of cell strings. +/// The font a compartment's symbols come from. /// -/// DXF format: `{\Fgdt;p}%%v0.5%%vA%%vB%%v%%v^J{\Fgdt;j}%%v0.1%%vA%%v%%v%%v` -/// - `^J` → row separator -/// - `%%v` → cell separator within a row -/// - `{\Fgdt;X}` → GDT symbol X (mapped to a text label) -fn parse_gdt_rows(raw: &str) -> Vec> { - raw.split("^J") +/// 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| { - row.split("%%v") - .map(|cell| substitute_gdt_codes(cell.trim())) - .collect() + 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() } -/// Replace `{\Fgdt;X}` sequences with a short ASCII label and strip other -/// MTEXT-style format codes `{\...}`. -fn substitute_gdt_codes(s: &str) -> String { - let mut out = String::new(); +/// 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 mut 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 == '{' { - // Collect to closing '}' - 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); - } - } - } - // Is it a GDT font switch? - if let Some(rest) = inner.strip_prefix("\\Fgdt;") { - // rest is the symbol letter(s) - if let Some(sym_char) = rest.chars().next() { - out.push_str(gdt_char_to_ascii(sym_char)); - } - } - // other format codes: skip - } else { - out.push(ch); + 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. } - out + runs } -/// Map a GDT font character to a short ASCII approximation. -fn gdt_char_to_ascii(c: char) -> &'static str { - match c { - 'a' => "SRT", // Straightness - 'b' => "FLT", // Flatness - 'c' => "FLT", // Flatness - 'd' => "PSF", // Profile of Surface - 'e' => "CYL", // Cylindricity - 'f' => "PRL", // Profile of Line - 'g' => "CIR", // Circularity - 'h' => "PAR", // Parallelism - 'i' => "SYM", // Symmetry - 'j' => "PRP", // Perpendicularity - 'k' => "PLN", // Profile of Line - 'l' => "(L)", // LMC - 'm' => "(M)", // MMC / Diameter - 'n' => "ANG", // Angularity - 'o' => "(o)", // at maximum material boundary - 'p' => "POS", // Position - 'q' => "(q)", - 'r' => "RUN", // Circular Runout - 's' => "(S)", // RFS / Regardless of Feature Size - 't' => "TRN", // Total Runout - 'u' => "CON", // Concentricity - 'v' => "(v)", - 'w' => "(w)", - _ => "?", +/// 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 ───────────────────────────────────────────── -/// One text cell of a feature-control frame, ready to become a `TextStroke` -/// with a `GlyphRun` so the cell text can render as SDF glyph quads (or, when +/// 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, @@ -123,7 +274,10 @@ struct TolCell { /// 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) -> (Vec>, Vec) { +fn tessellate_tolerance( + tol: &Tolerance, + doc: &acadrust::CadDocument, +) -> (Vec>, Vec) { if tol.text.is_empty() { return (vec![], vec![]); } @@ -134,35 +288,51 @@ fn tessellate_tolerance(tol: &Tolerance) -> (Vec>, Vec) { } // ── Metrics ────────────────────────────────────────────────────────── - let h = if tol.text_height > 1e-6 { - tol.text_height as f32 - } else { - 2.5_f32 - }; - // DIMGAP — stored on the entity (resolved from the dim style at creation). - // Fall back to AutoCAD's 0.35 × height convention only when missing. - let gap = if tol.dimension_gap > 1e-6 { - tol.dimension_gap as f32 - } else { - (h * 0.35).max(0.1) - }; - let cell_h = h + 2.0 * gap; - let char_w = h * 0.65; - let min_cell_w = h * 1.4; + // 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 }; - // Column widths: max across all rows - let max_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); - let mut col_widths: Vec = vec![0.0_f32; max_cols]; - for row in &rows { - for (ci, cell) in row.iter().enumerate() { - let w = (cell.len() as f32 * char_w).max(min_cell_w); - if ci < col_widths.len() { - col_widths[ci] = col_widths[ci].max(w); - } - } - } - let total_w: f32 = col_widths.iter().sum(); - let total_h = cell_h * rows.len() as 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); @@ -174,31 +344,41 @@ fn tessellate_tolerance(tol: &Tolerance) -> (Vec>, Vec) { let mut box_out: Vec> = Vec::new(); let mut cells: Vec = Vec::new(); - // ── Outer border ────────────────────────────────────────────────────── - box_out.push(vec![ - rot(0.0, 0.0), - rot(total_w, 0.0), - rot(total_w, total_h), - rot(0.0, total_h), - rot(0.0, 0.0), - ]); + // 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; - // ── Row separators ───────────────────────────────────────────────────── - for ri in 1..rows.len() { - let y = cell_h * ri as f32; - box_out.push(vec![rot(0.0, y), rot(total_w, y)]); + // ── 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), + ]); } - // ── Column dividers ──────────────────────────────────────────────────── - let mut x_cursor = 0.0_f32; - for ci in 0..col_widths.len().saturating_sub(1) { - x_cursor += col_widths[ci]; - for ri in 0..rows.len() { - if ci + 1 < rows[ri].len() { - let y0 = cell_h * ri as f32; - let y1 = y0 + cell_h; - box_out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]); - } + // ── 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)]); } } @@ -208,33 +388,39 @@ fn tessellate_tolerance(tol: &Tolerance) -> (Vec>, Vec) { // 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() { - let row_y = cell_h * ri as f32 + gap; + // 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 = if ci < col_widths.len() { - col_widths[ci] - } else { - 0.0 - }; + let cw = row_widths[ri][ci]; if !cell.is_empty() { - let text_w = cell.len() as f32 * char_w; - let tx = cell_x + (cw - text_w) * 0.5; - let (local_strokes, _) = - lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, "txt", cell); - // Glyph polylines rotated about the cell 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(); - cells.push(TolCell { - text: cell.clone(), - origin: rot(tx, row_y), - strokes, - height: h, - rotation: angle, - }); + // 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; } @@ -246,7 +432,7 @@ fn tessellate_tolerance(tol: &Tolerance) -> (Vec>, Vec) { // ── TruckConvertible ────────────────────────────────────────────────────────── impl TruckConvertible for Tolerance { - fn to_truck(&self, _document: &acadrust::CadDocument) -> Option { + fn to_truck(&self, document: &acadrust::CadDocument) -> Option { if self.text.is_empty() { return None; } @@ -258,7 +444,7 @@ impl TruckConvertible for Tolerance { ); // Build the feature-control frame in local space; origin stored as f64. - let (box_strokes, cells) = tessellate_tolerance(self); + 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 @@ -278,17 +464,26 @@ impl TruckConvertible for Tolerance { 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], + 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: "txt".to_string(), + font: cell.font.to_string(), height: cell.height, rotation: cell.rotation, width_factor: 1.0, oblique: 0.0, - tracking: 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, }), }); @@ -410,3 +605,349 @@ impl Transformable for Tolerance { }); } } + +#[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() { + use acadrust::EntityType; + let doc = acadrust::CadDocument::new(); + let mut tol = Tolerance::new(); + tol.text = "ABC".into(); + + let Some(TruckEntity { + object: TruckObject::Text(groups), + .. + }) = tol.to_truck(&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 + ); + } + } +} diff --git a/src/scene/text/lff.rs b/src/scene/text/lff.rs index fba204ba3..16f722635 100644 --- a/src/scene/text/lff.rs +++ b/src/scene/text/lff.rs @@ -50,6 +50,12 @@ const FONTS_SRC: &[(&str, &str)] = &[ ("symeteo", include_str!("../../../assets/fonts/symeteo.lff")), ("symusic", include_str!("../../../assets/fonts/symusic.lff")), ("unicode", include_str!("../../../assets/fonts/unicode.lff")), + // Geometric-tolerance symbols. Registered like any other font and + // keyed by the letter its `\Fgdt;X` escape carries, so a switch to it + // anywhere — a tolerance frame, an MTEXT, a text style literally named + // "gdt.shx" — resolves through the ordinary name lookup and needs no + // special case. It holds no Latin text: a letter IS a symbol here. + ("gdt", include_str!("../../../assets/fonts/gdt.lff")), ]; /// AutoCAD / DXF SHX font names → LFF stem. Names that already match a stem