fix(tolerance): draw the feature control frame properly

A tolerance drew one row of ASCII soup — "RUN | ANGtol(M) | ..." — about
a tenth of its proper size. Six defects, each independent.

Rows were split on the two-character string "^J". A DWG stores a raw
newline, and the text-DXF reader rewrites the on-disk ^J to one before we
see it, so that split never fired on either mainstream path and every row
collapsed into the first. It now normalises all four spellings the break
arrives in — including the \P our own DXF writer emits, which made
open-DWG → save-DXF → reopen asymmetric.

Symbols were replaced with ASCII labels from a table that was itself
wrong: it read r as circular runout, n as angularity, p as position. The
font's own encoding says concentricity, diameter, projected tolerance
zone, and the benchmark's pre-exploded reference agrees. That table was
an inversion of acadrust's, which is self-contradictory — it binds n to
two different meanings. Do not re-derive from it.

The frame is a proportion of the character height: a compartment spans
-h..+h about its row's centreline, so it is 2h tall and the margin falls
out of that. It was built as height + 2*DIMGAP instead, which is a
different entity's geometry — DIMGAP sizes the dimension line's break and
the rectangle around a basic dimension, where it genuinely applies twice
per axis. Even fed a self-consistent style that can only ever produce
1.5h, so it was wrong in kind rather than mis-fed.

Height came from the entity's own field, which a DWG never sets — the
reader carries only the style's handle, leaving a small non-zero default
that a "> 0" test happily used. It now resolves the style by handle first
and name second: a DWG records the handle and defaults the name, a DXF
the reverse, so matching on the name alone would have picked the wrong
style for every DWG-read tolerance. An entity may also override single
style variables on itself, and this one halves its character height that
way; ignoring that drew the frame several times too large.

Rows stacked upward, every row inherited the widest row's columns, and
one full-width border boxed them all — none of which showed while the
parse only ever produced a single row. The insertion point sits on the
first row's centreline; anchoring the frame's bottom-left there offset it
by its own height, and by an amount that changed with the row count.

Cell text ran together because the glyph run asked for no letter spacing
while the width measurement assumed the font's own, so the characters
crowded inside correctly-sized boxes. Compartments are also measured by
what they draw now: the pen advance stops one letter-space past the last
glyph, and counting that made every one of them too wide.

Symbols come from a bundled stroke font, restored from this repository's
own history: it shipped QCAD's gdt.cxf until the text engine moved from
CXF to LFF, which removed every .cxf and left nothing in their place —
the ASCII labels date from exactly there. Converted to LFF, keyed by the
letter its \Fgdt;X escape carries, and registered under that name like
any other font, so a switch to it resolves through the ordinary lookup:
an MTEXT carrying the escape, or a text style named gdt.shx, reaches it
too, with no code of their own. The geometry is public domain by explicit
waiver; provenance is in the file's header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-15 22:22:07 +03:00
commit c10e8dae44
3 changed files with 843 additions and 153 deletions

143
assets/fonts/gdt.lff Normal file
View file

@ -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

View file

@ -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<Vec<String>> {
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<Run>;
/// 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<Vec<Cell>> {
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<Cell> = 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<Cell>| !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<symbol font>;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<char> {
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<f64> {
// 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<i16> = 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<Vec<[f32; 2]>>,
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<[f32; 2]>>, Vec<TolCell>) {
fn tessellate_tolerance(
tol: &Tolerance,
doc: &acadrust::CadDocument,
) -> (Vec<Vec<[f32; 2]>>, Vec<TolCell>) {
if tol.text.is_empty() {
return (vec![], vec![]);
}
@ -134,35 +288,51 @@ fn tessellate_tolerance(tol: &Tolerance) -> (Vec<Vec<[f32; 2]>>, Vec<TolCell>) {
}
// ── 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<f32> = 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<Vec<f32>> = 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<[f32; 2]>>, Vec<TolCell>) {
let mut box_out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cells: Vec<TolCell> = 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<[f32; 2]>>, Vec<TolCell>) {
// 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<Vec<[f32; 2]>> = 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<Vec<[f32; 2]>> = 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<[f32; 2]>>, Vec<TolCell>) {
// ── TruckConvertible ──────────────────────────────────────────────────────────
impl TruckConvertible for Tolerance {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
fn to_truck(&self, document: &acadrust::CadDocument) -> Option<TruckEntity> {
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 "<font>:<text>" per run so a wrong font fails loudly.
let shown: Vec<Vec<String>> = rows
.iter()
.map(|row| {
row.iter()
.map(|cell| {
cell.iter()
.map(|r| format!("{}:{}", r.font, r.text))
.collect::<Vec<_>>()
.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<Vec<String>> {
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<f32> = 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
);
}
}
}

View file

@ -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