forked from 1b-agent/opencadstudio-upstream
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:
commit
5bdfe483da
3 files changed
+367
No files matched your search
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user