fix(export): render SDF text as vectors in PDF/print export (#385)

Since text became SDF-only (the legacy stroke-text path was removed), all
text — standalone TEXT/MTEXT and dimension text — rendered on-screen only as
GPU glyph quads carried on `WireModel::text_verts`. The CPU PDF/print exporter
draws only wire stroke `points` and hatch fills, so it silently dropped every
glyph: dimensions and text were missing from exported PDFs and prints
(present in v0.7.6).

Reconstruct vector text in the exporter instead of resurrecting the stroke
path, so the SDF render path and its caching are untouched:

- sdf_atlas: `GlyphAtlas::export_table()` snapshots each baked glyph's vector
  geometry (outline strokes + fill triangles, glyph space) keyed by its tile
  `uv_min`, re-resolved through `Face` so it honours the current TEXTFILL.
- pdf_export: a new `emit_text` pass walks each wire's `text_verts` (one
  6-vertex quad per glyph), recovers the glyph geometry by `uv_min`, and
  affine-maps it into the quad — `DrawLine` polylines for stroke (LFF/SHX)
  fonts, filled triangles for TrueType, plus solid decoration bars. Runs under
  the same rotation/scale/clip CTM as the wires, so it covers model space,
  paper space and windowed plots, and every SDF-text kind (text, dimension,
  multileader, table, tolerance).

Text embedded in complex linetypes is SDF-only with no stroke fallback and
stays out of scope.

Tests: a unit test asserts a stroke glyph emits vector ops within the glyph's
world bbox; an integration test drives a real TEXT + linear DIMENSION through
scene.entity_wires() -> export_pdf and checks text reaches the file. Verified
bug-first: with emit_text disabled the end-to-end test fails with the reported
symptom (text adds no content).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Griffin 2026-07-15 13:49:21 -07:00
commit 6b15884444
3 changed files with 367 additions and 0 deletions

View file

@ -113,6 +113,31 @@ pub struct AtlasEntry {
pub advance: f32,
}
/// A glyph's vector geometry recovered for CPU export (PDF / print), where the
/// SDF atlas texture can't be sampled. All coordinates are in the 9-unit glyph
/// space; `plane_min`/`plane_max` is the same tile rect the render quad spans,
/// so the exporter can map the geometry into a rendered quad by affine interp.
#[derive(Clone, Debug)]
pub struct GlyphExport {
pub plane_min: [f32; 2],
pub plane_max: [f32; 2],
/// Outline / centreline polylines (glyph units).
pub strokes: Vec<Vec<[f32; 2]>>,
/// Fill triangulation (glyph units, flat triples). Non-empty only for a
/// filled TrueType glyph — the exporter fills these and ignores `strokes`;
/// empty means "stroke `strokes`" (LFF pen font, or a hollow TTF glyph).
pub fill_tris: Vec<[f32; 2]>,
}
/// Pack a glyph quad's `uv_min` corner into a stable hash key for the export
/// table below. The two f32 UVs are exact copies of the baked `AtlasEntry`
/// values (via `GlyphQuad`), so bit-equality is a reliable identity as long as
/// the atlas hasn't been re-scaled by a growth since the quads were built —
/// which, for an export of an already-viewed drawing, it hasn't.
pub fn uv_key(uv_min: [f32; 2]) -> u64 {
((uv_min[0].to_bits() as u64) << 32) | uv_min[1].to_bits() as u64
}
/// A single-channel SDF glyph atlas plus a shelf packer.
pub struct GlyphAtlas {
width: u32,
@ -230,6 +255,34 @@ impl GlyphAtlas {
entry
}
/// Snapshot every baked glyph's vector geometry, keyed by its tile
/// `uv_min` ([`uv_key`]). CPU exporters (PDF / print) walk a text run's SDF
/// glyph quads, look each quad's `uv_min` up here, and re-emit the glyph as
/// vector strokes / fills instead of sampling the SDF texture. Re-resolves
/// each entry's outline through `Face` (the same source `get_or_insert`
/// baked from), so it reflects the current TEXTFILL mode.
pub fn export_table(&self) -> HashMap<u64, GlyphExport> {
let mut out = HashMap::with_capacity(self.entries.len());
for ((family, ch, bold), entry) in &self.entries {
let Some(entry) = entry else { continue };
let face = Face::resolve(family);
let Some(g) = face.glyph(*ch) else { continue };
// Bold LFF glyphs bake with a wider pen but the same centrelines;
// the exporter widens the pen itself, so the geometry is shared.
let _ = bold;
out.insert(
uv_key(entry.uv_min),
GlyphExport {
plane_min: entry.plane_min,
plane_max: entry.plane_max,
strokes: g.strokes.clone(),
fill_tris: g.fill_tris.clone(),
},
);
}
out
}
/// Grow the atlas taller (keeping the width, so packed X positions and their
/// normalized U stay valid) and rescale every cached entry's V for the new
/// height. Returns false once the height hits `MAX_ATLAS_PX` — text-heavy