forked from 1b-agent/opencadstudio-upstream
fix(export): project viewport text into paper space
Review follow-ups to #390, which fixed PDF/print text for model space but left the layout-plot path — the one issue #385 was filed from — broken. viewport_content_wires rewrote points/snap_pts/key_vertices/aabb and then cloned text_verts through untouched, so paper-space viewport text stayed at model (UTM) coordinates: a dimension at model (25000, 12000) plotted its lines onto the sheet and its glyphs kilometres off the page. A text-only wire fared worse — TEXT/MTEXT carry no stroke points since text went SDF-only, so it was dropped outright by the empty-clip and AABB rejections. Project the glyphs through the same proj_abs the snap points use (via the existing map_text_verts helper), cull per glyph on the quad centroid, count them towards the paper AABB, and dim them like the wire colour. Also in emit_text, which diverged from the wire pass it mirrors: - divide the pen by `scale`, or a Fit/windowed plot prints text as near-invisible hairlines while its own lines stay correct; - apply the CTB plot style, or a monochrome.ctb plot renders the lines black and leaves the text on its screen colour; - reset the dash pattern, which is persistent PDF state the wire pass leaves set — a HIDDEN-linetype last wire printed glyph outlines dashed. export_table's doc claimed it honoured TEXTFILL and widened the bold pen itself; it did neither. Gate fill_tris on textfill() so hollow-on-screen text exports hollow, carry the bold flag and widen the pen by the 1.7x the bake uses, and resolve Face once per family instead of once per glyph (the atlas mutex is held for that whole walk). uv_key identifies a glyph by its tile's uv_min, but grow_height rescales every entry's V and reset rewinds the packer, leaving already-built quads addressing the wrong tile — garbage on screen and a silent miss in the export table, i.e. #385 again under #347's grow-the-atlas conditions. Bump an atlas generation on both and fold it into the tessellation memo guard so the text re-lays-out instead. Quads built earlier in the same pass that grows are still stale for that pass; the guard heals them on the next one. Tests: the integration test's "some wire has text_verts" assert passed on pre-fix code too (the exporter ignored them; the scene always carried them), and its dimension half was satisfied by the TEXT alone — assert per entity handle instead, and stop colliding on fixed names in the shared temp root. The unit test's bbox came from the same quads the mapping reads, so it held by construction; pin the run's own world box instead. That still cannot catch a mirrored corner assignment — verified by mirroring it — so say so rather than claim coverage the assert doesn't have. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
5bdfe483da
commit
5018963d3d
6 files changed
+257
-72
No files matched your search
+83
-36
@@ -48,6 +48,15 @@ pub async fn pick_pdf_path_owned(_stem: String) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// mm to PDF points (1 mm = 2.834645 pt).
|
||||
const MM_TO_PT: f32 = 2.834645;
|
||||
/// `wire.line_weight_px` is the on-screen pixel weight: mm × (96/25.4) × 2.0,
|
||||
/// where the ×2 is a screen-legibility boost (see render.rs). Print wants the
|
||||
/// true physical weight, so undo both the 96-dpi scaling and the boost before
|
||||
/// converting to points — otherwise weights export ~2× too heavy in pixels
|
||||
/// (and the old `× 0.35278` left them inconsistent with the physical mm).
|
||||
const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0);
|
||||
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
|
||||
/// Export `wires` to a PDF file.
|
||||
@@ -171,7 +180,6 @@ fn build_pdf(
|
||||
// Clip rectangle (mm), applied in the pre-scale coordinate space so it
|
||||
// matches the wires drawn under the same CTM.
|
||||
if let Some((cx, cy, cw, ch)) = clip {
|
||||
const MM_TO_PT: f32 = 2.834645;
|
||||
ops.push(Op::DrawPolygon {
|
||||
polygon: Polygon {
|
||||
rings: vec![PolygonRing {
|
||||
@@ -204,14 +212,6 @@ fn build_pdf(
|
||||
}
|
||||
}
|
||||
|
||||
// mm to PDF points (1 mm = 2.834645 pt).
|
||||
const MM_TO_PT: f32 = 2.834645;
|
||||
// `wire.line_weight_px` is the on-screen pixel weight: mm × (96/25.4) × 2.0,
|
||||
// where the ×2 is a screen-legibility boost (see render.rs). Print wants the
|
||||
// true physical weight, so undo both the 96-dpi scaling and the boost before
|
||||
// converting to points — otherwise weights export ~2× too heavy in pixels
|
||||
// (and the old `× 0.35278` left them inconsistent with the physical mm).
|
||||
const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0);
|
||||
|
||||
// ── Hatch / wipeout fills (rendered before wires so wires draw on top,
|
||||
// matching paper_canvas ordering). Each `emit_hatch` sets its own
|
||||
@@ -327,7 +327,7 @@ fn build_pdf(
|
||||
// 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);
|
||||
emit_text(&mut ops, wires, ox, oy, scale, plot_style);
|
||||
|
||||
if needs_state {
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
@@ -547,7 +547,14 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
/// 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) {
|
||||
fn emit_text(
|
||||
ops: &mut Vec<Op>,
|
||||
wires: &[WireModel],
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
scale: f32,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
) {
|
||||
use crate::scene::text::sdf_atlas;
|
||||
|
||||
if wires.iter().all(|w| w.text_verts.is_empty()) {
|
||||
@@ -561,16 +568,34 @@ fn emit_text(ops: &mut Vec<Op>, wires: &[WireModel], ox: f32, oy: f32) {
|
||||
(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);
|
||||
// `Op::SetLineDashPattern` is persistent graphics state and the wire pass
|
||||
// above only re-emits it on change, so whatever the last wire needed is
|
||||
// still active here — without this reset a drawing whose last wire carries a
|
||||
// HIDDEN/CENTER linetype prints its glyph outlines dashed.
|
||||
ops.push(Op::SetLineDashPattern {
|
||||
dash: LineDashPattern::default(),
|
||||
});
|
||||
|
||||
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);
|
||||
// Mirror the wire pass: CTB colour/lineweight override by ACI, and the
|
||||
// `/ scale` that keeps pen widths absolute under the scaled CTM (a Fit
|
||||
// plot would otherwise render text as near-invisible hairlines).
|
||||
let mut ctb_color: Option<[f32; 3]> = None;
|
||||
let mut lw_override: Option<f32> = None;
|
||||
if let Some(ctb) = plot_style {
|
||||
if wire.aci > 0 {
|
||||
ctb_color = ctb.resolve_color(wire.aci);
|
||||
lw_override = ctb
|
||||
.resolve_lineweight(wire.aci)
|
||||
.map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6));
|
||||
}
|
||||
}
|
||||
let lw_pt = lw_override
|
||||
.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1) / scale.max(1e-6));
|
||||
|
||||
let mut gi = 0;
|
||||
while gi + 6 <= verts.len() {
|
||||
@@ -581,8 +606,12 @@ fn emit_text(ops: &mut Vec<Op>, wires: &[WireModel], ox: f32, oy: f32) {
|
||||
if a < 0.01 {
|
||||
continue;
|
||||
}
|
||||
let [r, g, b] =
|
||||
adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]]);
|
||||
// A CTB colour override wins over the white-sheet adaptation, exactly
|
||||
// as in the wire pass — else a monochrome.ctb plot plots the lines
|
||||
// black and leaves the text on its screen colour.
|
||||
let [r, g, b] = ctb_color.unwrap_or_else(|| {
|
||||
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]);
|
||||
@@ -629,11 +658,14 @@ fn emit_text(ops: &mut Vec<Op>, wires: &[WireModel], ox: f32, oy: f32) {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Stroke (LFF pen) font or hollow glyph: polylines.
|
||||
// Stroke (LFF pen) font or hollow glyph: polylines. Bold bakes
|
||||
// at a 1.7× pen over the same centrelines, so widen to match or
|
||||
// a bold run prints at regular weight.
|
||||
ops.push(Op::SetOutlineColor {
|
||||
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
|
||||
});
|
||||
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
|
||||
let pen = if ge.bold { lw_pt * 1.7 } else { lw_pt };
|
||||
ops.push(Op::SetOutlineThickness { pt: Pt(pen) });
|
||||
for stroke in &ge.strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
@@ -723,23 +755,23 @@ mod tests {
|
||||
// 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.
|
||||
//
|
||||
// The box is derived from the run's own parameters (origin + the 10.0 height
|
||||
// `text_wire` lays out at) rather than from the quads the mapping reads, so
|
||||
// it pins placement/scale independently: a wrong origin, a dropped `anno`, a
|
||||
// px-vs-mm mixup or an ox/oy misuse all move the ink out of it.
|
||||
//
|
||||
// It does NOT catch a mirrored corner assignment (bl <-> tl): the atlas plane
|
||||
// rect is the ink bbox plus a symmetric SDF spread, so a mirror maps the ink
|
||||
// box onto itself and no box-based assert can see it. The vertex order that
|
||||
// relies on is pinned by `text_gpu`'s own tests instead.
|
||||
#[test]
|
||||
fn sdf_text_emits_vector_ops_within_glyph_bounds() {
|
||||
fn sdf_text_emits_vector_ops_at_the_run_position() {
|
||||
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);
|
||||
emit_text(&mut ops, std::slice::from_ref(&wire), 0.0, 0.0, 1.0, None);
|
||||
|
||||
let lines = ops
|
||||
.iter()
|
||||
@@ -747,20 +779,35 @@ mod tests {
|
||||
.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.
|
||||
// "AB" at height 10 from (100, 50) inks a box starting at the origin, at
|
||||
// most 2 glyphs wide and one cap-height tall. `eps` covers the SDF plane
|
||||
// spread past the ink.
|
||||
let eps = 1.0_f32;
|
||||
let (x0, y0) = (origin[0] as f32, origin[1] as f32);
|
||||
let (x1, y1) = (x0 + 2.0 * 10.0 + eps, y0 + 10.0 + eps);
|
||||
let (mut gx0, mut gy0, mut gx1, mut gy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for o in &ops {
|
||||
if let Op::DrawLine { line } = o {
|
||||
for lp in &line.points {
|
||||
// Pt = mm × MM_TO_PT, and ox/oy are 0 here.
|
||||
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}]"
|
||||
x >= x0 - eps && x <= x1 && y >= y0 - eps && y <= y1,
|
||||
"glyph point ({x},{y}) outside the run's world box \
|
||||
[{x0},{y0}]..[{x1},{y1}] — mis-placed or mis-scaled mapping?"
|
||||
);
|
||||
gx0 = gx0.min(x);
|
||||
gy0 = gy0.min(y);
|
||||
gx1 = gx1.max(x);
|
||||
gy1 = gy1.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
// …and it must actually span the run, not collapse into a corner.
|
||||
assert!(
|
||||
gx1 - gx0 > 2.0 && gy1 - gy0 > 2.0,
|
||||
"glyph ink collapsed: [{gx0},{gy0}]..[{gx1},{gy1}]"
|
||||
);
|
||||
}
|
||||
|
||||
// End-to-end: a page whose only content is SDF text produces a larger PDF
|
||||
|
||||
@@ -4426,6 +4426,11 @@ impl Scene {
|
||||
mix(c.to_bits() as u64);
|
||||
}
|
||||
mix(avp.map(|h| h.value()).unwrap_or(0));
|
||||
// SDF glyph quads bake the atlas UV of each tile, so a growth or
|
||||
// a re-bake (which rescale / rewind every UV) makes memoized text
|
||||
// address the wrong tile — garbage on screen, and a silent miss in
|
||||
// the PDF export's glyph lookup (#385 under #347's conditions).
|
||||
mix(crate::scene::text::sdf_atlas::generation());
|
||||
g
|
||||
};
|
||||
let (memo_cell, guard_cell) = if resident {
|
||||
|
||||
@@ -315,7 +315,8 @@ impl WireModel {
|
||||
/// glyph quads live in `text_verts` (absolute-world double-single) — so a text
|
||||
/// ghost (MOVE / COPY / ROTATE / SCALE / STRETCH / MIRROR preview) must carry
|
||||
/// these along or the dragged text renders frozen at its source (issue #316).
|
||||
fn map_text_verts(
|
||||
/// Paper-space viewport projection uses it for the same reason (issue #385).
|
||||
pub(crate) fn map_text_verts(
|
||||
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
|
||||
f: impl Fn(f64, f64, f64) -> (f64, f64, f64),
|
||||
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
|
||||
|
||||
+51
-2
@@ -179,6 +179,33 @@ impl Scene {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// SDF glyph quads ride `text_verts` in absolute WCS, so they need
|
||||
// the same projection the points get — a bare clone leaves the
|
||||
// text at model (UTM) coordinates while its own dimension lines
|
||||
// move to the sheet, putting the glyphs kilometres off the page
|
||||
// (issue #385 for layout plots). Cull per glyph on the quad's
|
||||
// centroid: the text analogue of `clip_polyline_to_rect`, since a
|
||||
// glyph can't be split at the viewport border.
|
||||
let mut projected_text = if wire.text_verts.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
|
||||
let p = proj_abs(x, y, z);
|
||||
(p[0] as f64, p[1] as f64, p[2] as f64)
|
||||
})
|
||||
.chunks_exact(6)
|
||||
.filter(|quad| {
|
||||
let (sx, sy) = quad.iter().fold((0.0f32, 0.0f32), |(ax, ay), v| {
|
||||
(ax + v.pos[0] + v.pos_low[0], ay + v.pos[1] + v.pos_low[1])
|
||||
});
|
||||
let (cx, cy) = (sx / 6.0, sy / 6.0);
|
||||
cx.is_finite() && cy.is_finite() && in_vp(cx, cy)
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// Fast AABB pre-reject.
|
||||
let any_near = projected_pts.iter().any(|&[x, y, _]| {
|
||||
x.is_finite()
|
||||
@@ -202,13 +229,16 @@ impl Scene {
|
||||
);
|
||||
let aabb_hits =
|
||||
max_x >= vp_x0 && min_x <= vp_x1 && max_y >= vp_y0 && min_y <= vp_y1;
|
||||
if !any_near && !aabb_hits {
|
||||
// A text-only wire (TEXT / MTEXT carry no stroke `points` since
|
||||
// text went SDF-only) has nothing to pre-reject on, so gate both
|
||||
// rejections on the glyphs too or it never reaches the sheet.
|
||||
if !any_near && !aabb_hits && projected_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let clipped =
|
||||
clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz);
|
||||
if clipped.is_empty() {
|
||||
if clipped.is_empty() && projected_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -226,6 +256,17 @@ impl Scene {
|
||||
pmxx = pmxx.max(x);
|
||||
pmxy = pmxy.max(y);
|
||||
}
|
||||
// Glyphs count towards the paper AABB as well — a text-only wire
|
||||
// would otherwise stay UNBOUNDED and never pick.
|
||||
for v in &projected_text {
|
||||
let (x, y) = (v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]);
|
||||
if x.is_finite() && y.is_finite() {
|
||||
pmnx = pmnx.min(x);
|
||||
pmny = pmny.min(y);
|
||||
pmxx = pmxx.max(x);
|
||||
pmxy = pmxy.max(y);
|
||||
}
|
||||
}
|
||||
|
||||
// Project snap points + key vertices into the same paper frame,
|
||||
// keeping only those inside the viewport rect, so endpoint /
|
||||
@@ -252,8 +293,16 @@ impl Scene {
|
||||
|
||||
let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
|
||||
let [r, g, b, a] = adapted;
|
||||
// Glyphs carry their own per-vertex colour, so dim them through
|
||||
// the same adapt + 0.80/0.85 the wire colour below gets, or the
|
||||
// text reads brighter than its own dimension lines.
|
||||
for v in &mut projected_text {
|
||||
let [tr, tg, tb, ta] = view::render::adapt_to_bg(v.color, self.paper_bg_color);
|
||||
v.color = [tr * 0.80, tg * 0.80, tb * 0.80, ta * 0.85];
|
||||
}
|
||||
let mut out = wire.clone();
|
||||
out.points = clipped;
|
||||
out.text_verts = projected_text;
|
||||
// Paper coordinates are small sheet units — no relative-to-eye
|
||||
// residual is needed, and keeping the model wire's points_low
|
||||
// here would add a model-scale offset to the paper points.
|
||||
|
||||
@@ -42,6 +42,25 @@ pub fn text_atlas() -> &'static Mutex<GlyphAtlas> {
|
||||
ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(2048, 2048)))
|
||||
}
|
||||
|
||||
/// Bumped whenever already-baked entries' UVs stop addressing their tile:
|
||||
/// `grow_height` rescales every entry's V and `reset` rewinds the packer, but
|
||||
/// glyph quads built earlier keep the UVs they were laid out with. Those stale
|
||||
/// quads sample the wrong tile on screen and miss the export table entirely
|
||||
/// (silently dropping text from the PDF — issue #385 all over again, under
|
||||
/// issue #347's grow-the-atlas conditions). The tessellation memo guard folds
|
||||
/// this in, so a growth re-lays-out the text instead of leaving stale quads.
|
||||
static ATLAS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Current atlas UV generation — see [`ATLAS_GENERATION`]. Anything that caches
|
||||
/// glyph quads must treat a change here as "re-lay-out the text".
|
||||
pub fn generation() -> u64 {
|
||||
ATLAS_GENERATION.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn bump_generation() {
|
||||
ATLAS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// TEXTFILL system variable — `true` fills TrueType glyphs (default), `false`
|
||||
/// draws them hollow (outline only). Global, like the AutoCAD system variable.
|
||||
static TEXTFILL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
@@ -127,6 +146,8 @@ pub struct GlyphExport {
|
||||
/// 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]>,
|
||||
/// Baked with the 1.7× bold pen; the exporter widens its own pen to match.
|
||||
pub bold: bool,
|
||||
}
|
||||
|
||||
/// Pack a glyph quad's `uv_min` corner into a stable hash key for the export
|
||||
@@ -229,6 +250,8 @@ impl GlyphAtlas {
|
||||
/// next use (e.g. after TEXTFILL toggles between fill and outline).
|
||||
pub fn reset(&mut self) {
|
||||
*self = GlyphAtlas::new(self.width, self.height);
|
||||
// The packer rewound, so every previously handed-out UV is now stale.
|
||||
bump_generation();
|
||||
}
|
||||
|
||||
/// Look up a glyph's atlas placement, baking + packing it on first use.
|
||||
@@ -260,23 +283,39 @@ impl GlyphAtlas {
|
||||
/// 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.
|
||||
/// baked from), gated on TEXTFILL so a hollow-on-screen glyph exports hollow.
|
||||
pub fn export_table(&self) -> HashMap<u64, GlyphExport> {
|
||||
let fill_on = textfill();
|
||||
let mut out = HashMap::with_capacity(self.entries.len());
|
||||
// `Face::resolve` reparses a font, so resolve once per family rather than
|
||||
// once per baked glyph — a CJK drawing holds thousands of entries over a
|
||||
// handful of families, and the atlas mutex is held for this whole walk.
|
||||
let mut faces: HashMap<&str, Face> = HashMap::new();
|
||||
for ((family, ch, bold), entry) in &self.entries {
|
||||
let Some(entry) = entry else { continue };
|
||||
let face = Face::resolve(family);
|
||||
let face = faces
|
||||
.entry(family.as_str())
|
||||
.or_insert_with(|| 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(),
|
||||
// `bake_glyph` gates the texture fill on TEXTFILL the same way
|
||||
// (`textfill() && !g.fill_tris.is_empty()`); `Face::glyph` does
|
||||
// not, so without this the export ignores TEXTFILL 0 and prints
|
||||
// solid glyphs where the screen shows outlines.
|
||||
fill_tris: if fill_on {
|
||||
g.fill_tris.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
// Bold stroke glyphs bake at a 1.7× pen (`get_or_insert`) but
|
||||
// share the centrelines, so carry the flag and let the exporter
|
||||
// widen its own pen by the same factor.
|
||||
bold: *bold,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -304,6 +343,8 @@ impl GlyphAtlas {
|
||||
self.solid_uv[1] *= sy;
|
||||
self.height = new_h;
|
||||
self.dirty = true;
|
||||
// Every already-emitted quad's V now addresses the wrong tile.
|
||||
bump_generation();
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
// 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.
|
||||
// vanished from exported PDFs (present in v0.7.6).
|
||||
//
|
||||
// The op-level placement checks live in `pdf_export`'s own unit tests, which can
|
||||
// see `emit_text`'s ops. What only an integration test can cover is that the
|
||||
// real entity -> scene.entity_wires() -> export_pdf path carries text at all,
|
||||
// per entity kind — so that is what this asserts.
|
||||
use acadrust::entities::{Dimension, DimensionLinear, Text};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
@@ -15,35 +18,59 @@ use OpenCADStudio::scene::Scene;
|
||||
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));
|
||||
let text_h = 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)));
|
||||
// A linear dimension — its measurement value is synthesized as SDF text too,
|
||||
// and dimension text is what #385 was actually filed about.
|
||||
let dim = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(40.0, 0.0, 0.0));
|
||||
let dim_h = 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 per entity, not "some wire somewhere has text": both asserts below
|
||||
// would otherwise be satisfied by the TEXT alone, leaving a dimension-text
|
||||
// regression — the actual reported bug — green.
|
||||
// Tessellation names each wire after its entity handle (see the
|
||||
// `w.name.parse::<u64>()` lookup in the render pipeline).
|
||||
let has_text = |h: acadrust::types::Handle| {
|
||||
let want = h.value().to_string();
|
||||
wires
|
||||
.iter()
|
||||
.any(|w| w.name == want && !w.text_verts.is_empty())
|
||||
};
|
||||
assert!(
|
||||
text_wires > 0,
|
||||
"no text_verts on the export wire set — text/dim text would be missing from the PDF"
|
||||
has_text(text_h),
|
||||
"TEXT carries no glyph quads to the exporter"
|
||||
);
|
||||
assert!(
|
||||
has_text(dim_h),
|
||||
"DIMENSION carries no glyph quads to the exporter — dim text would be \
|
||||
missing from the PDF (#385)"
|
||||
);
|
||||
|
||||
// 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");
|
||||
// End-to-end: the same wire set with and without text. A private temp dir
|
||||
// keeps concurrent runs (and other users on a shared build host) from
|
||||
// colliding on a fixed name in the shared temp root.
|
||||
let dir = std::env::temp_dir().join(format!("ocs385-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
|
||||
let p_text = dir.join("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");
|
||||
|
||||
@@ -55,9 +82,22 @@ fn text_and_dim_reach_pdf_export() {
|
||||
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 p_bare = dir.join("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!(
|
||||
@@ -66,4 +106,6 @@ fn text_and_dim_reach_pdf_export() {
|
||||
with_text.len(),
|
||||
no_text.len()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user