Merge pull request #390 from KevinGriffin-new/fix/pdf-export-dimensions

Render SDF text as vectors in PDF/print export (#385).

Text became SDF-only, so all text — standalone TEXT/MTEXT and dimension
text — rode WireModel::text_verts as GPU glyph quads that the CPU PDF
exporter never sampled. Reconstruct the vector geometry at export time via
a new GlyphAtlas::export_table() keyed by the quads' atlas uv_min.
This commit is contained in:
Hakan Seven committed 2026-07-16 09:46:20 +03:00
commit 5bdfe483da
3 files changed
+367

No files matched your search

+245
View File
@@ -322,6 +322,13 @@ fn build_pdf(
flush_line(&mut ops, &segment);
}
// Text (SDF glyph quads) — re-emitted as vector strokes / fills. Text now
// renders on-screen only as textured SDF quads (`wire.text_verts`), which
// this CPU exporter can't sample, so without this pass all text — including
// dimension text — is missing from the PDF (issue #385). Drawn after the
// wires (on top) and under the same rotation/scale/clip CTM.
emit_text(&mut ops, wires, ox, oy);
if needs_state {
ops.push(Op::RestoreGraphicsState);
}
@@ -507,6 +514,164 @@ fn emit_hatch(ops: &mut Vec<Op>, hatch: &HatchModel, ox: f32, oy: f32) {
});
}
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
/// Absolute world XY of a glyph vertex (double-single high + low parts folded).
#[cfg(not(target_arch = "wasm32"))]
fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f32; 2] {
[v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]]
}
/// Adapt a text colour to the white sheet, mirroring the wire/hatch passes:
/// near-white / near-yellow (colour-7-on-white) → black, near-cyan → dark blue.
#[cfg(not(target_arch = "wasm32"))]
fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
if is_light || is_yellow {
[0.0, 0.0, 0.0]
} else if is_cyan {
[0.0, 0.15, 0.50]
} else {
[r, g, b]
}
}
/// Re-emit every wire's SDF text as vector geometry.
///
/// Each visible glyph rides on `wire.text_verts` as one 6-vertex quad (two
/// triangles) whose corners are the glyph's atlas `plane` rect run through the
/// text transform. We recover the glyph's outline / fill from the atlas by the
/// quad's `uv_min` and map it into that quad by affine interpolation of the
/// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType
/// glyph emits filled triangles, exactly where the SDF quad sits.
#[cfg(not(target_arch = "wasm32"))]
fn emit_text(ops: &mut Vec<Op>, wires: &[WireModel], ox: f32, oy: f32) {
use crate::scene::text::sdf_atlas;
if wires.iter().all(|w| w.text_verts.is_empty()) {
return;
}
// Snapshot the atlas' baked-glyph geometry once; drop the lock before use.
let (table, solid_key) = {
let Ok(atlas) = sdf_atlas::text_atlas().lock() else {
return;
};
(atlas.export_table(), sdf_atlas::uv_key(atlas.solid_uv()))
};
const MM_TO_PT: f32 = 2.834645;
// Match the wire pass: screen-px weight → true physical points.
const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0);
for wire in wires {
let verts = &wire.text_verts;
if verts.is_empty() {
continue;
}
let lw_pt = (wire.line_weight_px * LW_PX_TO_PT).max(0.1);
let mut gi = 0;
while gi + 6 <= verts.len() {
let quad = &verts[gi..gi + 6];
gi += 6;
let a = quad[0].color[3];
if a < 0.01 {
continue;
}
let [r, g, b] =
adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]]);
// Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl].
let bl = glyph_world_xy(&quad[0]);
let br = glyph_world_xy(&quad[1]);
let tr = glyph_world_xy(&quad[2]);
let tl = glyph_world_xy(&quad[5]);
// `tl` carries uv = (uv_min.x, uv_min.y) — the atlas tile key.
let key = sdf_atlas::uv_key([quad[5].uv[0], quad[5].uv[1]]);
let point = |wx: f32, wy: f32| Point::new(Mm(wx + ox), Mm(wy + oy));
if let Some(ge) = table.get(&key) {
// Affine basis of the quad: plane_min → bl, +x → br, +y → tl.
let (pmin, pmax) = (ge.plane_min, ge.plane_max);
let (sx, sy) = (pmax[0] - pmin[0], pmax[1] - pmin[1]);
if sx.abs() < 1e-9 || sy.abs() < 1e-9 {
continue;
}
let map = |p: [f32; 2]| -> Point {
let u = (p[0] - pmin[0]) / sx;
let v = (p[1] - pmin[1]) / sy;
let wx = bl[0] + u * (br[0] - bl[0]) + v * (tl[0] - bl[0]);
let wy = bl[1] + u * (br[1] - bl[1]) + v * (tl[1] - bl[1]);
point(wx, wy)
};
if !ge.fill_tris.is_empty() {
// Filled TrueType glyph: one filled triangle per triple.
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
for tri in ge.fill_tris.chunks_exact(3) {
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: vec![PolygonRing {
points: tri
.iter()
.map(|&p| LinePoint { p: map(p), bezier: false })
.collect(),
}],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
},
});
}
} else {
// Stroke (LFF pen) font or hollow glyph: polylines.
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
for stroke in &ge.strokes {
if stroke.len() < 2 {
continue;
}
ops.push(Op::DrawLine {
line: Line {
points: stroke
.iter()
.map(|&p| LinePoint { p: map(p), bezier: false })
.collect(),
is_closed: false,
},
});
}
}
} else if key == solid_key {
// Decoration bar (underline / overline / strike): the quad is a
// solid-texel rectangle — fill it directly from its corners.
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: vec![PolygonRing {
points: [bl, br, tr, tl]
.iter()
.map(|&c| LinePoint { p: point(c[0], c[1]), bezier: false })
.collect(),
}],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
},
});
}
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
@@ -536,4 +701,84 @@ mod tests {
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
}
// Build a WireModel carrying the SDF glyph quads for `text` in the embedded
// "txt" stroke font, laid out into the process-wide atlas emit_text reads.
fn text_wire(text: &str, origin: [f64; 3]) -> WireModel {
use crate::scene::pipeline::text_gpu::push_glyph_vertices;
use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas};
let quads = {
let mut atlas = sdf_atlas::text_atlas().lock().unwrap();
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, text)
};
assert!(!quads.is_empty(), "stroke glyphs laid out for {text:?}");
let mut verts = Vec::new();
push_glyph_vertices(&mut verts, &quads, origin, 1.0, [1.0, 0.0, 0.0, 1.0], 0.0);
WireModel {
text_verts: verts,
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
}
}
// Regression for #385: SDF text (`text_verts`) must be re-emitted as vector
// draw ops. Before the fix the exporter ignored `text_verts` entirely, so a
// text-only wire produced no glyph geometry — dimensions/text vanished.
#[test]
fn sdf_text_emits_vector_ops_within_glyph_bounds() {
let origin = [100.0, 50.0, 0.0];
let wire = text_wire("AB", origin);
// World bbox of the glyph quads — every emitted point must land inside.
let (mut nx, mut ny, mut xx, mut xy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for v in &wire.text_verts {
let (x, y) = (v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]);
nx = nx.min(x);
xx = xx.max(x);
ny = ny.min(y);
xy = xy.max(y);
}
let mut ops: Vec<Op> = Vec::new();
emit_text(&mut ops, std::slice::from_ref(&wire), 0.0, 0.0);
let lines = ops
.iter()
.filter(|o| matches!(o, Op::DrawLine { .. }))
.count();
assert!(lines > 0, "stroke text emitted no polylines (ops: {})", ops.len());
// Mapped points (Pt = mm × 2.834645, ox/oy = 0) sit within the bbox.
const MM_TO_PT: f32 = 2.834645;
let eps = 1.0; // mm slack for the SDF plane spread past the ink.
for o in &ops {
if let Op::DrawLine { line } = o {
for lp in &line.points {
let (x, y) = (lp.p.x.0 / MM_TO_PT, lp.p.y.0 / MM_TO_PT);
assert!(
x >= nx - eps && x <= xx + eps && y >= ny - eps && y <= xy + eps,
"glyph point ({x},{y}) outside bbox [{nx},{ny},{xx},{xy}]"
);
}
}
}
}
// End-to-end: a page whose only content is SDF text produces a larger PDF
// than the same page with the text stripped — proving text reaches the file.
#[test]
fn text_grows_the_pdf_vs_no_text() {
let wire = text_wire("HELLO", [20.0, 20.0, 0.0]);
let mut blank = wire.clone();
blank.text_verts.clear();
let with_text = build_pdf(&[wire], &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, None);
let no_text = build_pdf(&[blank], &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, None);
assert!(with_text.starts_with(b"%PDF"));
assert!(
with_text.len() > no_text.len(),
"text did not add content: {} !> {}",
with_text.len(),
no_text.len()
);
}
}
+53
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
+69
View File
@@ -0,0 +1,69 @@
// Regression for #385: text and dimension text must reach the PDF / print
// export. From v0.8.2 text renders on-screen only as GPU SDF glyph quads
// (`WireModel::text_verts`); the CPU PDF exporter used to draw only wire
// stroke `points`, so all text — standalone TEXT/MTEXT and dimension text —
// vanished from exported PDFs (present in v0.7.6). This drives the real
// entity -> scene.entity_wires() -> export_pdf path and asserts text is
// carried to the exporter and lands in the file.
use acadrust::entities::{Dimension, DimensionLinear, Text};
use acadrust::types::Vector3;
use acadrust::EntityType;
use OpenCADStudio::io::pdf_export::export_pdf;
use OpenCADStudio::scene::Scene;
#[test]
fn text_and_dim_reach_pdf_export() {
let mut scene = Scene::new();
// A standalone TEXT entity.
let t = Text::with_value("HELLO", Vector3::new(10.0, 10.0, 0.0)).with_height(5.0);
scene.add_entity(EntityType::Text(t));
// A linear dimension — its measurement value is synthesized as SDF text
// too, so it exercises the dimension-text path the reporters called out.
let dim = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(40.0, 0.0, 0.0),
);
scene.add_entity(EntityType::Dimension(Dimension::Linear(dim)));
let wires = scene.entity_wires();
// The regression symptom was that text produced no exportable geometry.
// It must now ride the export wire set as SDF glyph quads.
let text_wires = wires.iter().filter(|w| !w.text_verts.is_empty()).count();
assert!(
text_wires > 0,
"no text_verts on the export wire set — text/dim text would be missing from the PDF"
);
// End-to-end: exporting with the text present must produce a valid PDF that
// is larger than the same wire set with the text stripped — i.e. the glyph
// geometry actually reaches the file.
let dir = std::env::temp_dir();
let p_text = dir.join("ocs_385_with_text.pdf");
export_pdf(&wires, &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, &p_text, None)
.expect("export with text");
let with_text = std::fs::read(&p_text).expect("read pdf");
assert!(with_text.starts_with(b"%PDF"), "not a PDF");
let stripped: Vec<_> = wires
.iter()
.cloned()
.map(|mut w| {
w.text_verts.clear();
w
})
.collect();
let p_bare = dir.join("ocs_385_no_text.pdf");
export_pdf(&stripped, &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, &p_bare, None)
.expect("export without text");
let no_text = std::fs::read(&p_bare).expect("read pdf");
assert!(
with_text.len() > no_text.len(),
"text added no content to the PDF: {} !> {}",
with_text.len(),
no_text.len()
);
}