cad-editor/src/io/pdf_export.rs

831 lines
32 KiB
Rust
Raw Normal View History

// PDF export — converts the paper-space wire model to a PDF file using printpdf.
//
// Each WireModel becomes a sequence of DrawLine operations. NaN values in the
// points array act as segment separators (pen-up).
//
// Coordinate system: CAD uses mm units with origin at bottom-left and Y up.
// printpdf's Point::new(Mm, Mm) also has origin at bottom-left, so no Y-flip
// is needed — we shift the coordinates by (offset_x, offset_y) to place the
// drawing origin at the paper origin.
use crate::io::plot_style::PlotStyleTable;
use crate::scene::model::hatch_model::HatchModel;
#[cfg(not(target_arch = "wasm32"))]
use crate::scene::model::hatch_model::HatchPattern;
2026-05-12 10:36:15 +03:00
use crate::scene::WireModel;
#[cfg(not(target_arch = "wasm32"))]
2026-05-12 10:36:15 +03:00
use printpdf::{
Color, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode,
PdfDocument, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, WindingOrder,
2026-05-12 10:36:15 +03:00
};
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
use std::path::Path;
// The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via
// lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web
// build gets these stubs so the call sites still compile.
#[cfg(target_arch = "wasm32")]
pub fn export_pdf(
_wires: &[WireModel],
_hatches: &[HatchModel],
_wipeouts: &[HatchModel],
_paper_w: f64,
_paper_h: f64,
_offset_x: f32,
_offset_y: f32,
_rotation_deg: i32,
_scale: f32,
_clip: Option<(f32, f32, f32, f32)>,
_path: &Path,
_plot_style: Option<&PlotStyleTable>,
) -> Result<(), String> {
Err("PDF export is not available in the web version.".into())
}
#[cfg(target_arch = "wasm32")]
pub async fn pick_pdf_path_owned(_stem: String) -> Option<std::path::PathBuf> {
None
}
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
/// 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.
///
/// - `paper_w` / `paper_h`: page dimensions in mm (already swapped for 90°/270° by caller).
/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing
/// origin maps to the bottom-left corner of the page.
/// - `rotation_deg`: 0 | 90 | 180 | 270 — rotates the entire drawing on the page.
#[cfg(not(target_arch = "wasm32"))]
pub fn export_pdf(
wires: &[WireModel],
hatches: &[HatchModel],
wipeouts: &[HatchModel],
paper_w: f64,
paper_h: f64,
offset_x: f32,
offset_y: f32,
rotation_deg: i32,
scale: f32,
clip: Option<(f32, f32, f32, f32)>,
path: &Path,
plot_style: Option<&PlotStyleTable>,
) -> Result<(), String> {
2026-05-12 10:36:15 +03:00
let bytes = build_pdf(
wires,
hatches,
wipeouts,
2026-05-12 10:36:15 +03:00
paper_w as f32,
paper_h as f32,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
2026-05-12 10:36:15 +03:00
plot_style,
);
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
file.write_all(&bytes).map_err(|e| e.to_string())
}
/// Show a PDF save-file dialog and return the chosen path (or None if cancelled).
#[cfg(not(target_arch = "wasm32"))]
pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
rfd::AsyncFileDialog::new()
.set_title("Export as PDF")
.set_file_name(&format!("{stem}.pdf"))
.add_filter("PDF Files", &["pdf"])
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| crate::sys::handle_path(&h))
}
// ── PDF builder ───────────────────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
2026-05-12 10:36:15 +03:00
fn build_pdf(
wires: &[WireModel],
hatches: &[HatchModel],
wipeouts: &[HatchModel],
2026-05-12 10:36:15 +03:00
paper_w: f32,
paper_h: f32,
ox: f32,
oy: f32,
rotation_deg: i32,
scale: f32,
clip: Option<(f32, f32, f32, f32)>,
2026-05-12 10:36:15 +03:00
plot_style: Option<&PlotStyleTable>,
) -> Vec<u8> {
let mut doc = PdfDocument::new("Open CAD Studio Export");
let mut ops: Vec<Op> = Vec::new();
// White page background.
ops.push(Op::SetFillColor {
2026-05-12 10:36:15 +03:00
col: Color::Rgb(Rgb {
r: 1.0,
g: 1.0,
b: 1.0,
icc_profile: None,
}),
});
ops.push(Op::DrawRectangle {
rectangle: printpdf::Rect::from_wh(Mm(paper_w).into(), Mm(paper_h).into()),
});
// Round line caps/joins for CAD aesthetics.
2026-05-12 10:36:15 +03:00
ops.push(Op::SetLineCapStyle {
cap: LineCapStyle::Round,
});
ops.push(Op::SetLineJoinStyle {
join: LineJoinStyle::Round,
});
// Apply rotation/scale/clip transform if needed.
// PDF uses mm-based coordinate system with origin at bottom-left.
// We save state, apply a CTM (+ optional clip path), then restore after drawing.
let needs_state = rotation_deg != 0 || (scale - 1.0).abs() > 1e-6 || clip.is_some();
if needs_state {
let (cos_a, sin_a, tx, ty) = match rotation_deg {
2026-05-12 10:36:15 +03:00
90 => (0.0_f64, 1.0_f64, 0.0, paper_h as f64),
180 => (-1.0_f64, 0.0_f64, paper_w as f64, paper_h as f64),
270 => (0.0_f64, -1.0_f64, paper_w as f64, 0.0),
_ => (1.0_f64, 0.0_f64, 0.0, 0.0),
};
let s = scale as f64;
// PDF CTM: [a b c d e f] = [cos*s sin*s -sin*s cos*s tx ty]
ops.push(Op::SaveGraphicsState);
// Convert mm translation to points (1 mm = 2.834645 pt).
let tx_pt = (tx * 2.834645) as f32;
let ty_pt = (ty * 2.834645) as f32;
ops.push(Op::SetTransformationMatrix {
matrix: printpdf::CurTransMat::Raw([
(cos_a * s) as f32,
(sin_a * s) as f32,
(-(sin_a) * s) as f32,
(cos_a * s) as f32,
2026-05-12 10:36:15 +03:00
tx_pt,
ty_pt,
]),
});
// 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 {
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: vec![PolygonRing {
points: vec![
LinePoint {
p: Point { x: Pt(cx * MM_TO_PT), y: Pt(cy * MM_TO_PT) },
bezier: false,
},
LinePoint {
p: Point { x: Pt((cx + cw) * MM_TO_PT), y: Pt(cy * MM_TO_PT) },
bezier: false,
},
LinePoint {
p: Point {
x: Pt((cx + cw) * MM_TO_PT),
y: Pt((cy + ch) * MM_TO_PT),
},
bezier: false,
},
LinePoint {
p: Point { x: Pt(cx * MM_TO_PT), y: Pt((cy + ch) * MM_TO_PT) },
bezier: false,
},
],
}],
mode: PaintMode::Clip,
winding_order: WindingOrder::NonZero,
},
});
}
}
// ── Hatch / wipeout fills (rendered before wires so wires draw on top,
// matching paper_canvas ordering). Each `emit_hatch` sets its own
// fill / stroke colour, so the wire pass below starts fresh.
for hatch in wipeouts.iter().chain(hatches.iter()) {
emit_hatch(&mut ops, hatch, ox, oy);
}
let mut last_color: Option<[f32; 3]> = None;
let mut last_lw: Option<f32> = None;
// Current PDF dash array (empty = solid). Tracked so the dash op is only
// re-emitted when it actually changes between wires.
let mut last_dash: Option<Vec<i64>> = None;
for wire in wires {
let [mut r, mut g, mut b, a] = wire.color;
if a < 0.01 {
continue;
}
// Skip the paper-boundary wire — the white PDF background already provides it.
if wire.name == "__paper_boundary__" {
continue;
}
// Apply CTB plot style table overrides (color + lineweight).
let mut lw_override: Option<f32> = None;
if let Some(ctb) = plot_style {
if wire.aci > 0 {
if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) {
2026-05-12 10:36:15 +03:00
r = cr;
g = cg;
b = cb;
}
2026-05-12 10:36:15 +03:00
lw_override = ctb
.resolve_lineweight(wire.aci)
.map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6));
}
}
// Near-white and near-yellow (viewport active border) → dark grey for print
// (only when no CTB override was applied).
if lw_override.is_none() {
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
2026-05-12 10:36:15 +03:00
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
if is_light || is_yellow {
2026-05-12 10:36:15 +03:00
r = 0.0;
g = 0.0;
b = 0.0;
} else if is_cyan {
// Viewport border: print as dark blue.
2026-05-12 10:36:15 +03:00
r = 0.0;
g = 0.15;
b = 0.50;
}
}
2026-05-12 10:36:15 +03:00
if last_color
.map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01)
.unwrap_or(true)
{
ops.push(Op::SetOutlineColor {
2026-05-12 10:36:15 +03:00
col: Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
}),
});
last_color = Some([r, g, b]);
}
// Line weight: CTB override (in pt) or screen px → points. Divided by
// `scale` in both branches so pen widths stay absolute under the scaled
// CTM above — lineweights are independent of plot scale, so without this
// a Fit plot of a large window renders near-invisible hairlines.
let lw_pt = lw_override
.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1) / scale.max(1e-6));
if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) {
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
last_lw = Some(lw_pt);
}
// Linetype dash pattern. Without this every wire exported as a solid
// line regardless of its linetype (dashed / centre / dash-dot). (#155)
let dash_arr = dash_array_from_pattern(wire.pattern_length, &wire.pattern, MM_TO_PT);
if last_dash.as_deref() != Some(dash_arr.as_slice()) {
let dash = if dash_arr.is_empty() {
LineDashPattern::default()
} else {
LineDashPattern::from_array(&dash_arr, 0)
};
ops.push(Op::SetLineDashPattern { dash });
last_dash = Some(dash_arr);
}
// Emit segments (NaN = pen-up).
let mut segment: Vec<LinePoint> = Vec::new();
for &[x, y, _z] in &wire.points {
if x.is_nan() || y.is_nan() {
flush_line(&mut ops, &segment);
segment.clear();
} else {
segment.push(LinePoint {
p: Point::new(Mm(x + ox), Mm(y + oy)),
bezier: false,
});
}
}
flush_line(&mut ops, &segment);
}
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>
2026-07-15 13:49:21 -07:00
// 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.
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
emit_text(&mut ops, wires, ox, oy, scale, plot_style);
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>
2026-07-15 13:49:21 -07:00
if needs_state {
ops.push(Op::RestoreGraphicsState);
}
let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops);
doc.pages.push(page);
let mut warnings = Vec::new();
doc.save(&PdfSaveOptions::default(), &mut warnings)
}
/// Build a PDF dash array (in points) from a WireModel linetype pattern.
///
/// `pattern` holds the linetype run lengths in paper-mm: positive = dash,
/// negative = gap, exactly 0 = a dot, and trailing zeros are padding — so the
/// real length is the index of the last non-zero element + 1 (same convention
/// the wire shader uses). Returns an empty vec for a solid line. printpdf's
/// `LineDashPattern` holds at most six entries, so longer patterns are
/// truncated to three dash/gap pairs.
#[cfg(not(target_arch = "wasm32"))]
fn dash_array_from_pattern(pattern_length: f32, pattern: &[f32; 8], mm_to_pt: f32) -> Vec<i64> {
if pattern_length <= 1e-6 {
return Vec::new();
}
let count = match pattern.iter().rposition(|&v| v != 0.0) {
Some(i) => (i + 1).min(6),
None => return Vec::new(),
};
pattern[..count]
.iter()
// Round to whole points (printpdf dash entries are integers) and keep a
// 1 pt floor so a zero-length dot still prints as a short mark.
.map(|&v| (((v.abs() * mm_to_pt).round()) as i64).max(1))
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint]) {
if pts.len() < 2 {
return;
}
ops.push(Op::DrawLine {
line: Line {
points: pts.to_vec(),
is_closed: false,
},
});
}
/// Emit a single hatch / wipeout as a filled (or stroked, for pattern fills)
/// polygon. NaN sentinels in `hatch.boundary` split the path into multiple
/// rings so islands and holes render correctly under the even-odd rule.
/// Mirrors `scene::paper_canvas::draw_hatch`: solid → fill, pattern → outline,
/// gradient → solid fill of the averaged colour.
#[cfg(not(target_arch = "wasm32"))]
fn emit_hatch(ops: &mut Vec<Op>, hatch: &HatchModel, ox: f32, oy: f32) {
if hatch.boundary.is_empty() {
return;
}
fix(plot): correct hatch rendering in PDF / plot export Hatched content — especially fills nested inside a block INSERT, such as a title-block logo — plotted incorrectly: missing, mis-spaced, or with phantom bars. Five related fixes make the PDF/plot output match AutoCAD. 1. Export block-internal hatch fills. The export collected only hatches owned directly by the layout block, so a hatch nested in a block INSERT was dropped and printed as bare monochrome outlines. The insert-explosion the viewport already does is extracted into a shared `exploded_insert_hatch_models()` and called from both the viewport (`synced_hatch_models`) and the export (`paper_canvas_hatches`), so a plot draws block-internal hatches identically to the screen. 2. Honour the hatch's own stored pattern-line spacing. `hatch_model_from_dxf` re-derived pattern spacing from the name-matched catalog entry x pattern_scale, ignoring the resolved line geometry the DWG stores on the hatch. When a drawing was authored against a different base spacing (imperial 0.125 vs the catalog's metric 3.175 for ANSI31), lines came out up to ~25x too coarse and a dense fill collapsed to a few stray lines. When the hatch carries its own line geometry it is now used directly (identity scale/angle); the catalog path remains the fallback. 3. Fill far-from-origin pattern hatches. `pattern_segments` clamped the ABSOLUTE scan-line index to +/-MAX_LINES_PER_FAMILY. A fine-spaced hatch far from the pattern origin has large-magnitude indices at both ends but a small span, so the clamp inverted the range and emitted nothing — silently dropping the fill. Cap the line count (span) instead of the absolute index. 4. Skip TEXTBOX boundary paths. TEXTBOX boundary paths (flag bit 3) are text bounding-boxes AutoCAD derives for island detection; they are never drawn or filled. Treating one as a fill boundary painted its rectangle solid — a phantom bar. It is now skipped when building the fill boundary. 5. Print white/ACI-7 hatch fills black on paper. Hatch fills arrive adapted to the dark screen background, so a white/ACI-7 fill would vanish white-on-white on the sheet. Mirror the wire pass: near-white/near-yellow -> black, near-cyan -> dark blue, matching AutoCAD's colour-7-on-white plotting. Wipeouts keep their paper-white mask. Adds `tests/block_hatch_export.rs` covering: block-internal hatch reaches the export set, stored-line spacing is honoured, far-from-origin fills are not dropped, and TEXTBOX paths are not filled.
2026-07-06 12:24:10 +02:00
let [mut r, mut g, mut b, a] = hatch.color;
if a < 0.01 {
return;
}
fix(plot): correct hatch rendering in PDF / plot export Hatched content — especially fills nested inside a block INSERT, such as a title-block logo — plotted incorrectly: missing, mis-spaced, or with phantom bars. Five related fixes make the PDF/plot output match AutoCAD. 1. Export block-internal hatch fills. The export collected only hatches owned directly by the layout block, so a hatch nested in a block INSERT was dropped and printed as bare monochrome outlines. The insert-explosion the viewport already does is extracted into a shared `exploded_insert_hatch_models()` and called from both the viewport (`synced_hatch_models`) and the export (`paper_canvas_hatches`), so a plot draws block-internal hatches identically to the screen. 2. Honour the hatch's own stored pattern-line spacing. `hatch_model_from_dxf` re-derived pattern spacing from the name-matched catalog entry x pattern_scale, ignoring the resolved line geometry the DWG stores on the hatch. When a drawing was authored against a different base spacing (imperial 0.125 vs the catalog's metric 3.175 for ANSI31), lines came out up to ~25x too coarse and a dense fill collapsed to a few stray lines. When the hatch carries its own line geometry it is now used directly (identity scale/angle); the catalog path remains the fallback. 3. Fill far-from-origin pattern hatches. `pattern_segments` clamped the ABSOLUTE scan-line index to +/-MAX_LINES_PER_FAMILY. A fine-spaced hatch far from the pattern origin has large-magnitude indices at both ends but a small span, so the clamp inverted the range and emitted nothing — silently dropping the fill. Cap the line count (span) instead of the absolute index. 4. Skip TEXTBOX boundary paths. TEXTBOX boundary paths (flag bit 3) are text bounding-boxes AutoCAD derives for island detection; they are never drawn or filled. Treating one as a fill boundary painted its rectangle solid — a phantom bar. It is now skipped when building the fill boundary. 5. Print white/ACI-7 hatch fills black on paper. Hatch fills arrive adapted to the dark screen background, so a white/ACI-7 fill would vanish white-on-white on the sheet. Mirror the wire pass: near-white/near-yellow -> black, near-cyan -> dark blue, matching AutoCAD's colour-7-on-white plotting. Wipeouts keep their paper-white mask. Adds `tests/block_hatch_export.rs` covering: block-internal hatch reaches the export set, stored-line spacing is honoured, far-from-origin fills are not dropped, and TEXTBOX paths are not filled.
2026-07-06 12:24:10 +02:00
// Adapt hatch fills to the white sheet, mirroring the wire pass: colours
// arrive adapted to the (dark) screen background, so a white/ACI-7 fill
// would vanish white-on-white on paper. Force near-white/near-yellow → black
// and near-cyan → dark blue, matching AutoCAD's colour-7-on-white plotting.
// Genuine colours are untouched; WIPEOUTS keep their paper-white mask.
if hatch.name != "WIPEOUT_FILL" {
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 {
r = 0.0;
g = 0.0;
b = 0.0;
} else if is_cyan {
r = 0.0;
g = 0.15;
b = 0.50;
}
}
let world_ox = hatch.world_origin[0] as f32;
let world_oy = hatch.world_origin[1] as f32;
// Split the boundary into rings on every NaN-NaN separator.
let mut rings: Vec<PolygonRing> = Vec::new();
let mut current: Vec<LinePoint> = Vec::new();
for &[bx, by] in hatch.boundary.iter() {
if bx.is_nan() || by.is_nan() {
if current.len() >= 3 {
rings.push(PolygonRing { points: std::mem::take(&mut current) });
} else {
current.clear();
}
continue;
}
current.push(LinePoint {
p: Point::new(Mm(bx + world_ox + ox), Mm(by + world_oy + oy)),
bezier: false,
});
}
if current.len() >= 3 {
rings.push(PolygonRing { points: current });
}
if rings.is_empty() {
return;
}
let (paint_mode, fill_color) = match &hatch.pattern {
HatchPattern::Solid => (PaintMode::Fill, [r, g, b]),
HatchPattern::Pattern(_) => {
feat(hatch): rasterise PAT pattern lines for paper canvas + PDF Both the on-screen paper canvas and the PDF / print pipeline only knew how to draw the boundary outline of pattern hatches (ANSI31, ANSI37, dotted brick, etc.) — the actual hatch lines were a GPU-only feature, so any paper-space view or PDF export showed a hollow polygon where there should be ruled fill. Add a CPU rasteriser as `HatchModel::pattern_segments()`: - builds an edge list from the boundary, splitting at NaN sentinels and closing each sub-path so even-odd inside tests handle islands / holes; - for every PAT family computes the world-space step vector (`(dx,dy)` rotated into the family's frame, scaled by `model.scale`) and the inclusive k-range bounded by the boundary AABB plus a 4096- line safety cap; - intersects each line with every boundary edge, sorts the hit parameters, walks them in pairs (even-odd) and emits one segment per in/out interval. Dash patterns are walked along each clipped span with phase preserved via `t mod period`, matching the GPU shader's absolute-t dash evaluation; - pattern origin (`family.x0 / y0`) is rotated by `angle_offset` and scaled the same way as `hatch_batched.wgsl`, so PAT patterns with non-zero origins (brick / offset herringbone) line up with the GPU render. `paper_canvas::draw_hatch` and `pdf_export::emit_hatch` now route pattern hatches through this rasteriser; solid / gradient hatches keep their existing fast paths. The previous outline-only behaviour was a visible regression on every layout containing a ruled fill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:52:20 +03:00
// Pattern fills are emitted as raster line segments below; the
// outline polygon path itself is skipped because pattern
// hatches in real DXF do not draw their boundary as part of
// the fill.
(PaintMode::Clip, [r, g, b]) // sentinel — handled below
}
HatchPattern::Gradient { color2, .. } => {
// PDF gradients are stored in resource dictionaries; for the
// fast path we average the two colours, matching paper_canvas.
let avg = [
(r + color2[0]) * 0.5,
(g + color2[1]) * 0.5,
(b + color2[2]) * 0.5,
];
(PaintMode::Fill, avg)
}
};
feat(hatch): rasterise PAT pattern lines for paper canvas + PDF Both the on-screen paper canvas and the PDF / print pipeline only knew how to draw the boundary outline of pattern hatches (ANSI31, ANSI37, dotted brick, etc.) — the actual hatch lines were a GPU-only feature, so any paper-space view or PDF export showed a hollow polygon where there should be ruled fill. Add a CPU rasteriser as `HatchModel::pattern_segments()`: - builds an edge list from the boundary, splitting at NaN sentinels and closing each sub-path so even-odd inside tests handle islands / holes; - for every PAT family computes the world-space step vector (`(dx,dy)` rotated into the family's frame, scaled by `model.scale`) and the inclusive k-range bounded by the boundary AABB plus a 4096- line safety cap; - intersects each line with every boundary edge, sorts the hit parameters, walks them in pairs (even-odd) and emits one segment per in/out interval. Dash patterns are walked along each clipped span with phase preserved via `t mod period`, matching the GPU shader's absolute-t dash evaluation; - pattern origin (`family.x0 / y0`) is rotated by `angle_offset` and scaled the same way as `hatch_batched.wgsl`, so PAT patterns with non-zero origins (brick / offset herringbone) line up with the GPU render. `paper_canvas::draw_hatch` and `pdf_export::emit_hatch` now route pattern hatches through this rasteriser; solid / gradient hatches keep their existing fast paths. The previous outline-only behaviour was a visible regression on every layout containing a ruled fill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:52:20 +03:00
// Pattern hatches: rasterise the family lines clipped to the boundary
// and emit each as a stroked line. Skips the polygon outline entirely.
if matches!(hatch.pattern, HatchPattern::Pattern(_)) {
let segments = hatch.pattern_segments();
if segments.is_empty() {
return;
}
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
}),
});
for [a, b_pt] in segments {
ops.push(Op::DrawLine {
line: Line {
points: vec![
LinePoint {
p: Point::new(Mm(a[0] + ox), Mm(a[1] + oy)),
bezier: false,
},
LinePoint {
p: Point::new(Mm(b_pt[0] + ox), Mm(b_pt[1] + oy)),
bezier: false,
},
],
is_closed: false,
},
});
}
feat(hatch): rasterise PAT pattern lines for paper canvas + PDF Both the on-screen paper canvas and the PDF / print pipeline only knew how to draw the boundary outline of pattern hatches (ANSI31, ANSI37, dotted brick, etc.) — the actual hatch lines were a GPU-only feature, so any paper-space view or PDF export showed a hollow polygon where there should be ruled fill. Add a CPU rasteriser as `HatchModel::pattern_segments()`: - builds an edge list from the boundary, splitting at NaN sentinels and closing each sub-path so even-odd inside tests handle islands / holes; - for every PAT family computes the world-space step vector (`(dx,dy)` rotated into the family's frame, scaled by `model.scale`) and the inclusive k-range bounded by the boundary AABB plus a 4096- line safety cap; - intersects each line with every boundary edge, sorts the hit parameters, walks them in pairs (even-odd) and emits one segment per in/out interval. Dash patterns are walked along each clipped span with phase preserved via `t mod period`, matching the GPU shader's absolute-t dash evaluation; - pattern origin (`family.x0 / y0`) is rotated by `angle_offset` and scaled the same way as `hatch_batched.wgsl`, so PAT patterns with non-zero origins (brick / offset herringbone) line up with the GPU render. `paper_canvas::draw_hatch` and `pdf_export::emit_hatch` now route pattern hatches through this rasteriser; solid / gradient hatches keep their existing fast paths. The previous outline-only behaviour was a visible regression on every layout containing a ruled fill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:52:20 +03:00
return;
}
feat(hatch): rasterise PAT pattern lines for paper canvas + PDF Both the on-screen paper canvas and the PDF / print pipeline only knew how to draw the boundary outline of pattern hatches (ANSI31, ANSI37, dotted brick, etc.) — the actual hatch lines were a GPU-only feature, so any paper-space view or PDF export showed a hollow polygon where there should be ruled fill. Add a CPU rasteriser as `HatchModel::pattern_segments()`: - builds an edge list from the boundary, splitting at NaN sentinels and closing each sub-path so even-odd inside tests handle islands / holes; - for every PAT family computes the world-space step vector (`(dx,dy)` rotated into the family's frame, scaled by `model.scale`) and the inclusive k-range bounded by the boundary AABB plus a 4096- line safety cap; - intersects each line with every boundary edge, sorts the hit parameters, walks them in pairs (even-odd) and emits one segment per in/out interval. Dash patterns are walked along each clipped span with phase preserved via `t mod period`, matching the GPU shader's absolute-t dash evaluation; - pattern origin (`family.x0 / y0`) is rotated by `angle_offset` and scaled the same way as `hatch_batched.wgsl`, so PAT patterns with non-zero origins (brick / offset herringbone) line up with the GPU render. `paper_canvas::draw_hatch` and `pdf_export::emit_hatch` now route pattern hatches through this rasteriser; solid / gradient hatches keep their existing fast paths. The previous outline-only behaviour was a visible regression on every layout containing a ruled fill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:52:20 +03:00
// Solid / gradient: filled polygon path.
if matches!(paint_mode, PaintMode::Fill | PaintMode::FillStroke) {
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r: fill_color[0],
g: fill_color[1],
b: fill_color[2],
icc_profile: None,
}),
});
}
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings,
mode: paint_mode,
winding_order: WindingOrder::EvenOdd,
},
});
}
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>
2026-07-15 13:49:21 -07:00
// ── 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"))]
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
fn emit_text(
ops: &mut Vec<Op>,
wires: &[WireModel],
ox: f32,
oy: f32,
scale: f32,
plot_style: Option<&PlotStyleTable>,
) {
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>
2026-07-15 13:49:21 -07:00
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()))
};
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// `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(),
});
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>
2026-07-15 13:49:21 -07:00
for wire in wires {
let verts = &wire.text_verts;
if verts.is_empty() {
continue;
}
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// 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));
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>
2026-07-15 13:49:21 -07:00
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;
}
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// 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]])
});
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>
2026-07-15 13:49:21 -07:00
// 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 {
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// 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.
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>
2026-07-15 13:49:21 -07:00
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
let pen = if ge.bold { lw_pt * 1.7 } else { lw_pt };
ops.push(Op::SetOutlineThickness { pt: Pt(pen) });
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>
2026-07-15 13:49:21 -07:00
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::*;
#[test]
fn clip_and_scale_emit_pdf_bytes() {
let w = WireModel::solid(
"test".into(),
vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]],
WireModel::WHITE,
false,
);
let bytes = build_pdf(
&[w],
&[],
&[],
210.0,
297.0,
0.0,
0.0,
0,
2.0,
Some((10.0, 10.0, 100.0, 100.0)),
None,
);
// A valid PDF is produced (starts with the PDF header) and is non-trivial.
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
}
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>
2026-07-15 13:49:21 -07:00
// 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.
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
//
// 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.
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>
2026-07-15 13:49:21 -07:00
#[test]
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
fn sdf_text_emits_vector_ops_at_the_run_position() {
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>
2026-07-15 13:49:21 -07:00
let origin = [100.0, 50.0, 0.0];
let wire = text_wire("AB", origin);
let mut ops: Vec<Op> = Vec::new();
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
emit_text(&mut ops, std::slice::from_ref(&wire), 0.0, 0.0, 1.0, None);
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>
2026-07-15 13:49:21 -07:00
let lines = ops
.iter()
.filter(|o| matches!(o, Op::DrawLine { .. }))
.count();
assert!(lines > 0, "stroke text emitted no polylines (ops: {})", ops.len());
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// "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);
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>
2026-07-15 13:49:21 -07:00
for o in &ops {
if let Op::DrawLine { line } = o {
for lp in &line.points {
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// Pt = mm × MM_TO_PT, and ox/oy are 0 here.
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>
2026-07-15 13:49:21 -07:00
let (x, y) = (lp.p.x.0 / MM_TO_PT, lp.p.y.0 / MM_TO_PT);
assert!(
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
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?"
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>
2026-07-15 13:49:21 -07:00
);
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
gx0 = gx0.min(x);
gy0 = gy0.min(y);
gx1 = gx1.max(x);
gy1 = gy1.max(y);
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>
2026-07-15 13:49:21 -07:00
}
}
}
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 <noreply@anthropic.com>
2026-07-16 10:06:15 +03:00
// …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}]"
);
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>
2026-07-15 13:49:21 -07:00
}
// 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()
);
}
}