954 lines
39 KiB
Rust
954 lines
39 KiB
Rust
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<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| {
|
|
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()
|
|
}
|
|
|
|
/// 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<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 ─────────────────────────────────────────────
|
|
|
|
/// 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,
|
|
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<[f32; 2]>>, Vec<TolCell>) {
|
|
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<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);
|
|
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<[f32; 2]>> = Vec::new();
|
|
let mut cells: Vec<TolCell> = 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<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;
|
|
}
|
|
}
|
|
|
|
(box_out, cells)
|
|
}
|
|
|
|
// ── RenderConvertible ──────────────────────────────────────────────────────────
|
|
|
|
impl RenderConvertible for Tolerance {
|
|
fn to_render(&self, document: &acadrust::CadDocument) -> Option<RenderEntity> {
|
|
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<TextStroke> = 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<GripDef> {
|
|
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<PropSection> {
|
|
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::<f64>() 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 "<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() {
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|