diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 3e2983830..309ac1ede 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -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, 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, 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 = 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() + ); + } } diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index 68fd7e924..e388395eb 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -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>, + /// 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 { + 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 diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs new file mode 100644 index 000000000..f7a14efbf --- /dev/null +++ b/tests/pdf_export_text_check.rs @@ -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() + ); +}