Files
OneCAD-Draft/src/io/pdf_export.rs
T
Afonsop e3c86eca29 build: make the Trunk web-worker hook work on Windows
Trunk passes TRUNK_STAGING_DIR as an extended-length path (\?\C:\...)
on Windows, which MSYS sh tools cannot stat, so post_build failed and
the whole web build aborted. Strip the prefix with sed before use;
Linux CI paths are unaffected. Also apply rustfmt to pdf_export.rs.
2026-09-15 18:54:44 +01:00

1976 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
use crate::scene::model::hatch_model::HatchPattern;
use crate::scene::model::image_model::ImageModel;
use crate::scene::WireModel;
use printpdf::{
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument,
PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem,
WindingOrder,
};
use std::path::Path;
#[derive(Clone, Debug)]
pub struct PlotWire {
pub wire: WireModel,
pub draw_depth: f32,
}
impl std::ops::Deref for PlotWire {
type Target = WireModel;
fn deref(&self) -> &Self::Target {
&self.wire
}
}
/// Decoded image geometry plus inherited block/viewport clip boundaries.
#[derive(Clone, Debug)]
pub struct PlotImage {
pub image: ImageModel,
pub clips: Vec<Vec<[f64; 2]>>,
}
// Rendering is fully portable; only persistence differs — desktop writes the
// bytes atomically through a temp file, web delivers them as a browser download.
/// 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. Convert the 96-dpi
/// pixels to points while retaining the viewport's lineweight visibility
/// boost, so "As displayed" output has the same visual hierarchy as the
/// canvas instead of making 0.35 mm ByLayer outlines look half as thick.
const LW_PX_TO_PT: f32 = MM_TO_PT / (96.0 / 25.4);
const SCREEN_DOT_MM: f32 = 25.4 / 96.0;
/// Image resolution preset from the plot dialog's Quality dropdown. Vector
/// output is resolution-independent; this only caps raster content.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlotQuality {
Low,
#[default]
Normal,
High,
}
impl PlotQuality {
/// Accepts the dialog's raw string, including legacy names.
pub fn from_dialog(value: &str) -> Self {
match value {
"Low" | "Draft" => Self::Low,
"High" | "Presentation" | "Maximum" | "Custom" => Self::High,
_ => Self::Normal,
}
}
/// Maximum effective DPI for embedded raster images; `None` keeps native pixels.
fn target_dpi(self) -> Option<u32> {
match self {
Self::Low => Some(150),
Self::Normal => Some(300),
Self::High => None,
}
}
}
/// Output controls shared by preview, PDF export, and printer rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PdfPlotOptions {
pub object_lineweights: bool,
pub scale_lineweights: bool,
pub transparency: bool,
pub stamp: bool,
pub merge_lines: bool,
pub quality: PlotQuality,
}
/// End indexes of the first paper/model render group in each flat input list.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PlotGroupSplits {
pub wires: usize,
pub hatches: usize,
pub wipeouts: usize,
pub images: usize,
}
#[derive(Default)]
pub struct PlotContent {
pub wires: std::sync::Arc<Vec<PlotWire>>,
pub hatches: Vec<HatchModel>,
pub wipeouts: Vec<HatchModel>,
pub images: Vec<PlotImage>,
pub group_splits: PlotGroupSplits,
}
/// Owned geometry and settings for one PDF page.
pub struct PdfPageInput {
pub content: PlotContent,
/// Page dimensions in mm, after any 90/270-degree rotation.
pub paper_w: f64,
pub paper_h: f64,
/// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates.
pub offset_x: f64,
pub offset_y: f64,
pub rotation_deg: i32,
pub scale: f32,
pub clip: Option<(f32, f32, f32, f32)>,
pub options: PdfPlotOptions,
pub plot_style: Option<PlotStyleTable>,
}
impl Default for PdfPlotOptions {
fn default() -> Self {
Self {
object_lineweights: true,
scale_lineweights: false,
transparency: false,
stamp: false,
merge_lines: false,
quality: PlotQuality::Normal,
}
}
}
// ── Public entry point ────────────────────────────────────────────────────
/// Export one page to a PDF file.
pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> {
export_pdf_pages(std::slice::from_ref(page), path, None)
}
/// Export several independently sized pages into one PDF file.
pub fn export_pdf_pages(
pages: &[PdfPageInput],
path: &Path,
plot_style: Option<&PlotStyleTable>,
) -> Result<(), String> {
if pages.is_empty() {
return Err("No pages were selected.".into());
}
let bytes = render_pdf_bytes(pages, plot_style)?;
persist_pdf(path, &bytes)
}
#[cfg(not(target_arch = "wasm32"))]
fn persist_pdf(path: &Path, bytes: &[u8]) -> Result<(), String> {
write_pdf_atomically(path, bytes)
}
#[cfg(target_arch = "wasm32")]
fn persist_pdf(path: &Path, bytes: &[u8]) -> Result<(), String> {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing.pdf".into());
crate::sys::download_bytes(&name, bytes);
Ok(())
}
/// Write a complete PDF beside the destination, then replace it atomically.
#[cfg(not(target_arch = "wasm32"))]
fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
let temp_path = super::save_temp_path(path);
if let Err(error) = std::fs::write(&temp_path, bytes) {
let _ = std::fs::remove_file(&temp_path);
return Err(format!("Failed to write PDF data: {error}"));
}
if let Err(error) = super::replace_save_file(&temp_path, path) {
let _ = std::fs::remove_file(&temp_path);
return Err(format!("Failed to replace PDF file: {error}"));
}
Ok(())
}
fn image_clip(ops: &mut Vec<Op>, rings: Vec<Vec<[f32; 2]>>) {
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: rings
.into_iter()
.map(|ring| PolygonRing {
points: ring
.into_iter()
.map(|[x, y]| LinePoint {
p: Point { x: Pt(x), y: Pt(y) },
bezier: false,
})
.collect(),
})
.collect(),
mode: PaintMode::Clip,
winding_order: WindingOrder::EvenOdd,
},
});
}
/// Pixel dimensions that keep the image at most `target_dpi` when drawn at
/// `effective_dpi`. Returns the input unchanged when already within target
/// (or when a dimension would collapse), so High/native never resamples.
fn downsample_target(width: u32, height: u32, effective_dpi: f64, target_dpi: u32) -> (u32, u32) {
let factor = target_dpi as f64 / effective_dpi;
if width == 0 || height == 0 || !factor.is_finite() || factor >= 1.0 {
return (width, height);
}
let scaled = |dim: u32| ((dim as f64 * factor).round() as u64).clamp(1, dim as u64) as u32;
(scaled(width), scaled(height))
}
fn emit_image(
doc: &mut PdfDocument,
resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
ops: &mut Vec<Op>,
plot: &PlotImage,
ox: f64,
oy: f64,
scale: f32,
options: PdfPlotOptions,
) -> Result<(), String> {
let image = &plot.image;
let expected_byte_count = (image.width as usize)
.checked_mul(image.height as usize)
.and_then(|n| n.checked_mul(4));
if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) {
return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into());
}
if image.verts.len() < 3 || image.verts.len() % 3 != 0 {
return Err("Cannot plot bitmap: incomplete triangle geometry.".into());
}
if options.transparency && image.opacity <= 0.0 {
return Ok(());
}
let to_page = |high: [f32; 3], low: [f32; 3]| {
[
((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32,
((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32,
]
};
let corners: [[f32; 2]; 4] =
std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i]));
if !corners.iter().flatten().all(|v| v.is_finite())
|| !image.verts.iter().all(|v| {
to_page(v.pos, v.pos_low)
.iter()
.chain(&v.uv)
.all(|c| c.is_finite())
})
|| !plot
.clips
.iter()
.all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite()))
|| !image.opacity.is_finite()
{
return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into());
}
// Quality caps the raster resolution (High = native pixels, the previous
// behaviour). Effective DPI is pixels per inch on paper: the page-space
// corner edges are points, / MM_TO_PT and × the page CTM `scale` give
// on-paper mm.
let mut width = image.width;
let mut height = image.height;
let mut pixels: std::borrow::Cow<[u8]> = std::borrow::Cow::Borrowed(image.pixels.as_slice());
if let Some(target) = options.quality.target_dpi() {
let dist =
|a: [f32; 2], b: [f32; 2]| ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let width_edge = {
let d = dist(corners[0], corners[1]);
if d > 0.0 {
d
} else {
dist(corners[0], corners[3])
}
};
let height_edge = {
let d = dist(corners[0], corners[3]);
if d > 0.0 {
d
} else {
dist(corners[0], corners[1])
}
};
// A degenerate (zero-area) placement keeps the native pixels.
if width_edge > 0.0 && height_edge > 0.0 {
let dpi_x = width as f64 * 25.4 / (width_edge / MM_TO_PT * scale) as f64;
let dpi_y = height as f64 * 25.4 / (height_edge / MM_TO_PT * scale) as f64;
// One shared factor from the denser axis preserves the pixel aspect ratio.
let (w, h) = downsample_target(width, height, dpi_x.max(dpi_y), target);
// Skip token reductions (< 5%): a Lanczos pass costs more than it saves.
if (w != width || h != height) && w as f64 / (width as f64) < 0.95 {
if let Some(resized) = image::RgbaImage::from_raw(
width,
height,
image.pixels.as_ref().clone(),
)
.map(|buffer| {
image::imageops::resize(&buffer, w, h, image::imageops::FilterType::Lanczos3)
.into_raw()
}) {
width = w;
height = h;
pixels = std::borrow::Cow::Owned(resized);
}
}
}
}
// Keyed on the FINAL (post-resample) dims: the same pixels Arc can be
// placed at different effective DPIs (several inserts at different
// scales, layouts plotted with different quality settings), and each
// XObject must match its own draw transform's scale factors. Identical
// placements still dedupe on the shared key.
let key = (
std::sync::Arc::as_ptr(&image.pixels) as usize,
width,
height,
);
let id = resources
.entry(key)
.or_insert_with(|| {
let id = printpdf::XObjectId::new();
// add_image clones its argument; the public map accepts owned pixels.
doc.resources.xobjects.map.insert(
id.clone(),
printpdf::XObject::Image(printpdf::RawImage {
pixels: printpdf::RawImageData::U8(pixels.into_owned()),
width: width as usize,
height: height as usize,
data_format: printpdf::RawImageFormat::RGBA8,
tag: Vec::new(),
}),
);
id
})
.clone();
ops.push(Op::SaveGraphicsState);
if options.transparency && image.opacity < 1.0 {
// printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside
// this image's saved state so the nonstroking alpha is correct.
let alpha = image.opacity.clamp(0.0, 1.0);
let gs = doc.add_graphics_state(
ExtendedGraphicsState::default()
.with_current_fill_alpha(alpha)
.with_current_stroke_alpha(alpha),
);
ops.push(Op::LoadGraphicsState { gs });
}
for clip in &plot.clips {
image_clip(
ops,
vec![clip
.iter()
.map(|p| {
[
((p[0] + ox) * MM_TO_PT as f64) as f32,
((p[1] + oy) * MM_TO_PT as f64) as f32,
]
})
.collect()],
);
}
let draw = |ops: &mut Vec<Op>, matrix: [f32; 6]| {
ops.push(Op::SetTransformationMatrix {
matrix: printpdf::CurTransMat::Raw(matrix),
});
ops.push(Op::UseXobject {
id: id.clone(),
// Cancel printpdf's pixel-size transform: matrix maps the PDF
// image's unit square directly onto the CAD quad in page points.
// The scale factors divide the EMBEDDED dims, which differ from
// the source when quality downsampling kicked in above.
transform: printpdf::XObjectTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / width as f32),
scale_y: Some(1.0 / height as f32),
..Default::default()
},
});
};
let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]];
let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]];
let affine = (0..2).all(|axis| {
(corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs()
<= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0)
});
if affine {
// A single draw preserves intrinsic IMAGE clipping without overlapping image draws.
image_clip(
ops,
image
.verts
.chunks_exact(3)
.map(|tri| {
tri.iter()
.map(|vertex| to_page(vertex.pos, vertex.pos_low))
.collect()
})
.collect(),
);
draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]);
} else {
// PDF transforms are affine; perspective sampling is approximate.
for triangle in image.verts.chunks_exact(3) {
let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low));
let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]);
let matrix = image_triangle_matrix(page, uv)
.filter(|matrix| matrix.iter().all(|v| v.is_finite()))
.ok_or("Cannot plot bitmap: degenerate texture mapping.")?;
ops.push(Op::SaveGraphicsState);
image_clip(ops, vec![page.to_vec()]);
draw(ops, matrix);
ops.push(Op::RestoreGraphicsState);
}
}
ops.push(Op::RestoreGraphicsState);
Ok(())
}
fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> {
let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = u[0] * v[1] - v[0] * u[1];
if determinant.abs() < 1e-12 {
return None;
}
let axes: [[f32; 2]; 2] = std::array::from_fn(|i| {
[
((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant,
(u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant,
]
});
Some([
axes[0][0],
axes[1][0],
axes[0][1],
axes[1][1],
p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1],
p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1],
])
}
/// Show a parented PDF save-file dialog and return the chosen path.
///
/// The parent comes from `iced::window::run`, keeping the portal request tied
/// to the visible app window on Wayland instead of silently resolving to
/// `None` on desktops that reject a parentless save dialog (#537).
#[cfg(not(target_arch = "wasm32"))]
pub fn pick_pdf_path_owned(
stem: String,
parent: &dyn iced::window::Window,
) -> Option<std::path::PathBuf> {
let path = crate::sys::blocking_file_dialog()
.set_parent(parent)
.set_title(crate::t!("Export as PDF").as_ref())
.set_file_name(&format!("{stem}.pdf"))
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.save_file()?;
crate::config::remember_dialog_dir(&path);
Some(path)
}
/// Web: no save dialog — the export continues with a virtual filename and
/// the generated bytes are delivered as a browser download.
#[cfg(target_arch = "wasm32")]
pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(format!("{stem}.pdf")))
}
// ── PDF builder ───────────────────────────────────────────────────────────
pub fn render_pdf_bytes(
pages: &[PdfPageInput],
plot_style: Option<&PlotStyleTable>,
) -> Result<Vec<u8>, String> {
let mut doc = PdfDocument::new("Open CAD Studio Export");
// Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
// Allocation addresses cannot be reused by another source during this export.
let mut image_resources = std::collections::HashMap::new();
for (index, page) in pages.iter().enumerate() {
append_pdf_page(&mut doc, &mut image_resources, page, plot_style)
.map_err(|error| format!("Page {}: {error}", index + 1))?;
}
let mut warnings = Vec::new();
Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
}
fn append_pdf_page(
doc: &mut PdfDocument,
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
page: &PdfPageInput,
fallback_plot_style: Option<&PlotStyleTable>,
) -> Result<(), String> {
let PlotContent {
wires,
hatches,
wipeouts,
images,
group_splits,
} = &page.content;
let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32);
let (ox, oy) = (page.offset_x, page.offset_y);
let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip);
let plot_style = page.plot_style.as_ref().or(fallback_plot_style);
let options = page.options;
let mut ops: Vec<Op> = Vec::new();
// White page background.
ops.push(Op::SetFillColor {
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()),
});
let normal_blend = if options.merge_lines {
let merge = doc.add_graphics_state(
ExtendedGraphicsState::default().with_blend_mode(BlendMode::multiply()),
);
let normal = doc.add_graphics_state(
ExtendedGraphicsState::default().with_blend_mode(BlendMode::normal()),
);
ops.push(Op::SaveGraphicsState);
ops.push(Op::LoadGraphicsState { gs: merge });
Some(normal)
} else {
None
};
// Round line caps/joins for CAD aesthetics.
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 {
// `paper_w`/`paper_h` are already the effective, rotation-swapped
// page dimensions. A 90° turn maps x' = page_w - y, y' = x;
// 270° maps x' = y, y' = page_h - x.
90 => (0.0_f64, 1.0_f64, paper_w as f64, 0.0),
180 => (-1.0_f64, 0.0_f64, paper_w as f64, paper_h as f64),
270 => (0.0_f64, -1.0_f64, 0.0, paper_h as f64),
_ => (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,
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,
},
});
}
}
let (first_wires, second_wires) = wires.split_at(group_splits.wires.min(wires.len()));
let (first_hatches, second_hatches) = hatches.split_at(group_splits.hatches.min(hatches.len()));
let (first_wipeouts, second_wipeouts) =
wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len()));
let (first_images, second_images) = images.split_at(group_splits.images.min(images.len()));
for (wires, hatches, wipeouts, images) in [
(first_wires, first_hatches, first_wipeouts, first_images),
(second_wires, second_hatches, second_wipeouts, second_images),
] {
enum DrawItem<'a> {
WireFill(&'a PlotWire),
Hatch(&'a HatchModel),
Image(&'a PlotImage),
Wire(&'a PlotWire),
Text(&'a PlotWire),
}
let mut draw_items =
Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len());
let mut sequence = 0usize;
for wire in wires {
if !wire.fill_tris.is_empty() {
draw_items.push((wire.draw_depth, 0u8, sequence, DrawItem::WireFill(wire)));
sequence += 1;
}
draw_items.push((wire.draw_depth, 2u8, sequence, DrawItem::Wire(wire)));
sequence += 1;
if !wire.text_verts.is_empty() {
draw_items.push((wire.draw_depth, 3u8, sequence, DrawItem::Text(wire)));
sequence += 1;
}
}
for hatch in wipeouts.iter().chain(hatches.iter()) {
draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch)));
sequence += 1;
}
for image in images {
draw_items.push((
image.image.draw_depth,
1u8,
sequence,
DrawItem::Image(image),
));
sequence += 1;
}
draw_items.sort_by(|a, b| {
a.0.total_cmp(&b.0)
.then_with(|| a.1.cmp(&b.1))
.then_with(|| a.2.cmp(&b.2))
});
let mut last_color: Option<[f32; 3]> = None;
let mut last_lw: Option<f32> = None;
let mut last_cap = Some(LineCapStyle::Round);
let mut last_join = Some(LineJoinStyle::Round);
// 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 (_, _, _, item) in draw_items {
let wire = match item {
DrawItem::WireFill(wire) => {
emit_wire_fills(
&mut ops,
std::slice::from_ref(&wire.wire),
ox,
oy,
plot_style,
scale,
options,
normal_blend.as_ref(),
);
last_color = None;
last_lw = None;
last_dash = None;
continue;
}
DrawItem::Hatch(hatch) => {
emit_hatch(
&mut ops,
hatch,
ox,
oy,
plot_style,
scale,
options,
normal_blend.as_ref(),
);
last_color = None;
last_lw = None;
last_dash = None;
continue;
}
DrawItem::Text(wire) => {
emit_text(
&mut ops,
std::slice::from_ref(&wire.wire),
ox,
oy,
scale,
plot_style,
options,
);
last_color = None;
last_lw = None;
last_dash = None;
continue;
}
DrawItem::Image(image) => {
emit_image(
doc,
image_resources,
&mut ops,
image,
ox,
oy,
scale,
options,
)?;
continue;
}
DrawItem::Wire(wire) => wire,
};
let [mut r, mut g, mut b, a] = wire.color;
if a < 0.01 {
continue;
}
// Skip screen-only paper helpers. The PDF page supplies its own white
// boundary, and the printable-area rectangle is a UI guide, not ink.
if matches!(
wire.name.as_str(),
"__paper_boundary__" | "paper_printable_area"
) {
continue;
}
// Apply CTB plot style table overrides (color + lineweight).
let mut lw_override: Option<f32> = None;
let mut screening = 1.0;
let mut color_overridden = false;
let mut cap = None;
let mut join = None;
if let Some(ctb) = plot_style {
if wire.aci > 0 {
if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) {
r = cr;
g = cg;
b = cb;
color_overridden = true;
}
lw_override = ctb
.resolve_lineweight(wire.aci)
.map(|mm| (mm * MM_TO_PT).max(0.1));
screening = ctb.resolve_screening(wire.aci);
if let Some(entry) = ctb.aci_entries.get(wire.aci as usize) {
cap = match entry.end_style {
0 => Some(LineCapStyle::Butt),
1 | 3 => Some(LineCapStyle::ProjectingSquare),
2 => Some(LineCapStyle::Round),
_ => None,
};
join = match entry.join_style {
0 => Some(LineJoinStyle::Miter),
1 | 3 => Some(LineJoinStyle::Bevel),
2 => Some(LineJoinStyle::Round),
_ => None,
};
}
}
}
// Near-white and near-yellow (viewport active border) → dark grey for print
// (only when no CTB override was applied).
if !color_overridden {
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 {
// Viewport border: print as dark blue.
r = 0.0;
g = 0.15;
b = 0.50;
}
}
[r, g, b] = plotted_color([r, g, b], a, screening, options);
let cap = cap.unwrap_or(LineCapStyle::Round);
if last_cap != Some(cap) {
ops.push(Op::SetLineCapStyle { cap });
last_cap = Some(cap);
}
let join = join.unwrap_or(LineJoinStyle::Round);
if last_join != Some(join) {
ops.push(Op::SetLineJoinStyle { join });
last_join = Some(join);
}
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)
{
let color = Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
});
ops.push(Op::SetOutlineColor { col: color.clone() });
ops.push(Op::SetFillColor { col: color });
last_color = Some([r, g, b]);
}
// Line weight: style override or object weight. Normal output divides
// by the page transform so physical pen widths stay constant; the
// scale-lineweights option deliberately keeps the transformed width.
//
// A wide polyline is the exception: its band is a geometric width in
// drawing units, so it must SCALE with the plot (no `/ scale`). Stroke
// the centre-line at `world_width`, converted mm → pt exactly like the
// geometry coordinates so the CTM scale renders the band at its true
// size; the linetype dash pattern below then strokes it dashed. This
// replaces the model-space hatch band that the shader-band change
// dropped, and overrides any CTB pen weight (the width is geometry, not
// a lineweight).
let pen_divisor = if options.scale_lineweights {
1.0
} else {
scale.max(1e-6)
};
let lw_pt = if wire.world_width > 0.0 {
wire.world_width * MM_TO_PT
} else {
let physical = if options.object_lineweights {
lw_override.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1))
} else {
0.1
};
physical / pen_divisor
};
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);
let stationed = !dash_arr.is_empty() && wire.pattern_stations.len() > wire.points.len();
if stationed {
if last_dash.as_ref().is_none_or(|dash| !dash.is_empty()) {
ops.push(Op::SetLineDashPattern {
dash: LineDashPattern::default(),
});
last_dash = Some(Vec::new());
}
for index in 0..wire.points.len().saturating_sub(1) {
if !wire.points[index][0].is_finite() || !wire.points[index + 1][0].is_finite()
{
continue;
}
let start = wire.point_world(index, paper_h as f64 / scale.max(1e-6) as f64);
let end = wire.point_world(index + 1, paper_h as f64 / scale.max(1e-6) as f64);
for [from, to] in visible_station_ranges(
wire.pattern_stations[index],
wire.pattern_stations[index + 1],
wire.pattern_length,
&wire.pattern,
) {
let point = |t: f32| LinePoint {
p: Point::new(
Mm((start.x + (end.x - start.x) * t as f64 + ox) as f32),
Mm((start.y + (end.y - start.y) * t as f64 + oy) as f32),
),
bezier: false,
};
flush_line(&mut ops, &[point(from), point(to)], None);
}
}
continue;
}
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.clone());
}
// Emit segments (NaN = pen-up). Points are the "high" half of a
// double-single pair; fold in the `points_low` residual and cancel the
// offset in f64 before narrowing. Dropping the residual (or narrowing
// first) snaps a UTM drawing onto the f32 grid — ~3 cm across, ~50 cm
// along northing — which is exactly the distortion the plot showed while
// low-coordinate drawings came out clean. The result is a sheet-mm value
// in single digits, so f32 is lossless from here.
let mut segment: Vec<LinePoint> = Vec::new();
let dot_radius = (wire.name == "viewport_hatch_pattern")
.then_some(Pt(SCREEN_DOT_MM * MM_TO_PT / (2.0 * scale.max(1e-6))));
for (pi, &[x, y, _z]) in wire.points.iter().enumerate() {
if x.is_nan() || y.is_nan() {
flush_line(&mut ops, &segment, dot_radius);
segment.clear();
} else {
let point = wire.point_world(pi, paper_h as f64 / scale.max(1e-6) as f64);
let wx = (point.x + ox) as f32;
let wy = (point.y + oy) as f32;
segment.push(LinePoint {
p: Point::new(Mm(wx), Mm(wy)),
bezier: false,
});
}
}
flush_line(&mut ops, &segment, dot_radius);
}
}
if needs_state {
ops.push(Op::RestoreGraphicsState);
}
if options.merge_lines {
ops.push(Op::RestoreGraphicsState);
}
if options.stamp {
emit_plot_stamp(&mut ops);
}
let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops);
doc.pages.push(page);
Ok(())
}
/// 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.
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()
}
fn visible_station_ranges(
start: f32,
end: f32,
pattern_length: f32,
pattern: &[f32; 8],
) -> Vec<[f32; 2]> {
let count = pattern
.iter()
.rposition(|value| *value != 0.0)
.map_or(0, |i| i + 1);
if count == 0 || pattern_length <= 1e-6 {
return vec![[0.0, 1.0]];
}
let dot = 1.0 / MM_TO_PT;
let mut elements: Vec<(f32, bool)> = pattern[..count]
.iter()
.map(|value| (if *value == 0.0 { dot } else { value.abs() }, *value >= 0.0))
.collect();
let total: f32 = elements.iter().map(|(length, _)| *length).sum();
if total <= 1e-6 {
return vec![[0.0, 1.0]];
}
let factor = pattern_length / total;
for (length, _) in &mut elements {
*length *= factor;
}
let delta = end - start;
let state = |station: f32, forward: bool| {
let mut phase = station.rem_euclid(pattern_length);
if !forward && phase <= 1e-6 {
phase = pattern_length;
}
let mut offset = 0.0;
if forward {
for &(length, drawn) in &elements {
let end = offset + length;
if phase < end - 1e-6 {
return (drawn, end - phase);
}
offset = end;
}
(elements[0].1, elements[0].0)
} else {
offset = pattern_length;
for &(length, drawn) in elements.iter().rev() {
offset -= length;
if phase > offset + 1e-6 {
return (drawn, phase - offset);
}
}
let last = elements[elements.len() - 1];
(last.1, last.0)
}
};
if delta.abs() <= 1e-6 {
return state(start, true)
.0
.then_some([0.0, 1.0])
.into_iter()
.collect();
}
let mut ranges = Vec::new();
let mut t = 0.0;
while t < 1.0 - 1e-6 {
let station = start + delta * t;
let (drawn, remaining) = state(station, delta > 0.0);
let next = (t + remaining / delta.abs()).clamp(t + 1e-6, 1.0);
if drawn {
ranges.push([t, next]);
}
t = next;
}
ranges
}
fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint], dot_radius: Option<Pt>) {
if pts.len() < 2 {
return;
}
if let Some(radius) = dot_radius {
let first = pts[0].p;
let coincident = pts.iter().skip(1).all(|point| {
(point.p.x.0 - first.x.0).abs() <= 1e-6 && (point.p.y.0 - first.y.0).abs() <= 1e-6
});
if coincident {
emit_round_dot(ops, first, radius);
return;
}
}
ops.push(Op::DrawLine {
line: Line {
points: pts.to_vec(),
is_closed: false,
},
});
}
fn emit_round_dot(ops: &mut Vec<Op>, center: Point, radius: Pt) {
const SIDES: usize = 12;
let points = (0..SIDES)
.map(|index| {
let angle = std::f32::consts::TAU * index as f32 / SIDES as f32;
LinePoint {
p: Point {
x: Pt(center.x.0 + radius.0 * angle.cos()),
y: Pt(center.y.0 + radius.0 * angle.sin()),
},
bezier: false,
}
})
.collect();
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: vec![PolygonRing { points }],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
},
});
}
fn plotted_color(rgb: [f32; 3], alpha: f32, screening: f32, options: PdfPlotOptions) -> [f32; 3] {
let amount = screening.clamp(0.0, 1.0)
* if options.transparency {
alpha.clamp(0.0, 1.0)
} else {
1.0
};
[
1.0 - (1.0 - rgb[0]) * amount,
1.0 - (1.0 - rgb[1]) * amount,
1.0 - (1.0 - rgb[2]) * amount,
]
}
fn emit_wire_fills(
ops: &mut Vec<Op>,
wires: &[WireModel],
ox: f64,
oy: f64,
plot_style: Option<&PlotStyleTable>,
scale: f32,
options: PdfPlotOptions,
normal_blend: Option<&ExtendedGraphicsStateId>,
) {
for wire in wires {
if wire.fill_tris.is_empty() {
continue;
}
let styled_pattern = plot_style.and_then(|table| {
(wire.aci > 0)
.then(|| table.aci_entries.get(wire.aci as usize))
.flatten()
.and_then(|entry| {
(65..=72)
.contains(&entry.fill_style)
.then(|| {
crate::scene::model::hatch_model::plot_style_fill_pattern(
entry.fill_style,
)
})
.flatten()
})
});
if let Some(pattern) = styled_pattern {
for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() {
let mut boundary = Vec::with_capacity(4);
for (point_index, point) in triangle.iter().enumerate() {
let index = triangle_index * 3 + point_index;
let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]);
boundary.push([point[0] + low[0], point[1] + low[1]]);
}
boundary.push(boundary[0]);
let hatch = HatchModel {
pattern_origin: None,
render_instance: wire.render_instance.clone(),
world_origin: [0.0, 0.0],
boundary: std::sync::Arc::new(boundary),
boundary_wcs: None,
fill_plane: None,
fill_plane_boundary: None,
boundary_exterior: None,
boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: pattern.clone(),
name: "PLOTSTYLE".to_string(),
color: wire.color,
aci: wire.aci,
line_weight_px: wire.line_weight_px,
angle_offset: 0.0,
scale: 1.0 / scale.max(1.0e-6),
draw_depth: wire.depth_override.unwrap_or(0.0),
};
emit_hatch(
ops,
&hatch,
ox,
oy,
plot_style,
scale,
options,
normal_blend,
);
}
continue;
}
let [mut r, mut g, mut b, a] = wire.color;
if a < 0.01 {
continue;
}
let mut screening = 1.0;
let mut color_overridden = false;
if let Some(table) = plot_style {
if wire.aci > 0 {
if let Some(color) = table.resolve_color(wire.aci) {
[r, g, b] = color;
color_overridden = true;
}
screening = table.resolve_screening(wire.aci);
}
}
if !color_overridden {
[r, g, b] = adapt_text_color([r, g, b]);
}
[r, g, b] = plotted_color([r, g, b], a, screening, options);
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
}),
});
for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() {
let mut points = Vec::with_capacity(3);
for (point_index, &[x, y, _]) in triangle.iter().enumerate() {
let index = triangle_index * 3 + point_index;
let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]);
points.push(LinePoint {
p: Point::new(
Mm((x as f64 + low[0] as f64 + ox) as f32),
Mm((y as f64 + low[1] as f64 + oy) as f32),
),
bezier: false,
});
}
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: vec![PolygonRing { points }],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
},
});
}
}
}
fn emit_plot_stamp(ops: &mut Vec<Op>) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".into());
let label = format!("Open CAD Studio | {user} | {timestamp}");
ops.extend([
Op::SaveGraphicsState,
Op::StartTextSection,
Op::SetTextCursor {
pos: Point::new(Mm(4.0), Mm(3.0)),
},
Op::SetFont {
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
size: Pt(6.0),
},
Op::SetFillColor {
col: Color::Rgb(Rgb {
r: 0.25,
g: 0.25,
b: 0.25,
icc_profile: None,
}),
},
Op::ShowText {
items: vec![TextItem::Text(label)],
},
Op::EndTextSection,
Op::RestoreGraphicsState,
]);
}
/// 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.
fn emit_hatch(
ops: &mut Vec<Op>,
hatch: &HatchModel,
ox: f64,
oy: f64,
plot_style: Option<&PlotStyleTable>,
scale: f32,
options: PdfPlotOptions,
normal_blend: Option<&ExtendedGraphicsStateId>,
) {
if hatch.boundary.is_empty() {
return;
}
let mut styled_hatch = None;
if let Some(table) = plot_style {
if hatch.aci > 0 && matches!(hatch.pattern, HatchPattern::Solid) {
if let Some(pattern) = table.aci_entries.get(hatch.aci as usize).and_then(|entry| {
crate::scene::model::hatch_model::plot_style_fill_pattern(entry.fill_style)
}) {
let mut model = hatch.clone();
model.pattern = pattern;
model.scale = 1.0 / scale.max(1.0e-6);
styled_hatch = Some(model);
}
}
}
let hatch = styled_hatch.as_ref().unwrap_or(hatch);
let [mut r, mut g, mut b, a] = hatch.color;
if a < 0.01 {
return;
}
// 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 for a readable white-sheet result.
// Genuine colours are untouched; WIPEOUTS keep their paper-white mask.
let is_wipeout = hatch.name == "WIPEOUT_FILL";
let mut screening = 1.0;
let mut lw_override = None;
let mut color_overridden = false;
if !is_wipeout {
if let Some(table) = plot_style {
if hatch.aci > 0 {
if let Some([cr, cg, cb]) = table.resolve_color(hatch.aci) {
r = cr;
g = cg;
b = cb;
color_overridden = true;
}
screening = table.resolve_screening(hatch.aci);
lw_override = table
.resolve_lineweight(hatch.aci)
.map(|mm| (mm * MM_TO_PT).max(0.1));
}
}
}
if is_wipeout {
// Screen wipeouts match the configured canvas colour; printed
// wipeouts must mask with the white paper colour.
r = 1.0;
g = 1.0;
b = 1.0;
} else if !color_overridden && !(hatch.aci == 7 && matches!(hatch.pattern, HatchPattern::Solid))
{
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;
}
}
[r, g, b] = plotted_color([r, g, b], a, screening, options);
// `boundary` holds f32 offsets from the f64 `world_origin`, so resolve the
// pair in f64 and only narrow once the offset has cancelled — casting
// `world_origin` to f32 first re-introduces the ~0.5 m UTM quantisation the
// boundary-relative encoding exists to avoid.
let (world_ox, world_oy) = (hatch.world_origin[0], hatch.world_origin[1]);
// 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;
}
let px = (bx as f64 + world_ox + ox) as f32;
let py = (by as f64 + world_oy + oy) as f32;
current.push(LinePoint {
p: Point::new(Mm(px), Mm(py)),
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(_) => {
// 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 second = if color_overridden {
[r, g, b]
} else {
plotted_color(
adapt_text_color([color2[0], color2[1], color2[2]]),
color2[3],
screening,
options,
)
};
let avg = [
(r + second[0]) * 0.5,
(g + second[1]) * 0.5,
(b + second[2]) * 0.5,
];
(PaintMode::Fill, avg)
}
};
// 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 physical = if options.object_lineweights {
lw_override.unwrap_or_else(|| (hatch.line_weight_px * LW_PX_TO_PT).max(0.1))
} else {
0.1
};
let divisor = if options.scale_lineweights {
1.0
} else {
scale.max(1e-6)
};
let segments = hatch.pattern_segments_for_plot();
if segments.is_empty() {
return;
}
let color = Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
});
ops.push(Op::SetOutlineColor { col: color.clone() });
ops.push(Op::SetFillColor { col: color });
ops.push(Op::SetOutlineThickness {
pt: Pt(physical / divisor),
});
// Pattern dashes are already materialized by `pattern_segments`.
// Clear any linetype left by the preceding paper/model render group.
ops.push(Op::SetLineDashPattern {
dash: LineDashPattern::default(),
});
for [a, b_pt] in segments {
// `pattern_segments` returns absolute world f64; cancel the offset
// before narrowing, as everywhere else in this file.
let (ax, ay) = ((a[0] + ox) as f32, (a[1] + oy) as f32);
let (bx, by) = ((b_pt[0] + ox) as f32, (b_pt[1] + oy) as f32);
let points = vec![
LinePoint {
p: Point::new(Mm(ax), Mm(ay)),
bezier: false,
},
LinePoint {
p: Point::new(Mm(bx), Mm(by)),
bezier: false,
},
];
let dot_radius = Pt(SCREEN_DOT_MM * MM_TO_PT / (2.0 * scale.max(1e-6)));
flush_line(ops, &points, Some(dot_radius));
}
return;
}
// 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,
}),
});
}
if is_wipeout {
if let Some(gs) = normal_blend {
ops.push(Op::SaveGraphicsState);
ops.push(Op::LoadGraphicsState { gs: gs.clone() });
}
}
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings,
mode: paint_mode,
winding_order: WindingOrder::EvenOdd,
},
});
if is_wipeout && normal_blend.is_some() {
ops.push(Op::RestoreGraphicsState);
}
}
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
/// Absolute world XY of a glyph vertex (double-single high + low parts folded).
///
/// The fold must happen in f64: the pair exists because the absolute coordinate
/// does not fit an f32, so `pos + pos_low` evaluated in f32 rounds straight back
/// to `pos` and throws away the residual it was carrying.
fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f64; 2] {
[
v.pos[0] as f64 + v.pos_low[0] as f64,
v.pos[1] as f64 + v.pos_low[1] as f64,
]
}
/// 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.
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.
fn emit_text(
ops: &mut Vec<Op>,
wires: &[WireModel],
ox: f64,
oy: f64,
scale: f32,
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
) {
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()))
};
// `Op::SetLineDashPattern` is persistent graphics state and the wire pass
// above only re-emits it on change, so whatever the last wire needed is
// still active here — without this reset a drawing whose last wire carries a
// HIDDEN/CENTER linetype prints its glyph outlines dashed.
ops.push(Op::SetLineDashPattern {
dash: LineDashPattern::default(),
});
for wire in wires {
let verts = &wire.text_verts;
if verts.is_empty() {
continue;
}
// Mirror the wire pass: indexed style color, screening, and pen width.
let mut ctb_color: Option<[f32; 3]> = None;
let mut lw_override: Option<f32> = None;
let mut screening = 1.0;
if let Some(ctb) = plot_style {
if wire.aci > 0 {
ctb_color = ctb.resolve_color(wire.aci);
lw_override = options
.object_lineweights
.then(|| {
ctb.resolve_lineweight(wire.aci).map(|mm| {
let divisor = if options.scale_lineweights {
1.0
} else {
scale.max(1e-6)
};
(mm * MM_TO_PT).max(0.1) / divisor
})
})
.flatten();
screening = ctb.resolve_screening(wire.aci);
}
}
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;
}
// 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 rgb = ctb_color.unwrap_or_else(|| {
adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]])
});
let [r, g, b] = plotted_color(rgb, a, screening, options);
// 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]]);
// Cancel the offset in f64, then narrow: the sheet-mm result is a
// small number even when the world coordinate is UTM-scale.
let point = |wx: f64, wy: f64| Point::new(Mm((wx + ox) as f32), Mm((wy + oy) as f32));
if let Some(ge) = table.get(&key) {
// Affine basis of the quad: plane_min → bl, +x → br, +y → tl.
// The glyph-space maths is small and stays f32; only the lift into
// world coordinates needs f64.
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) as f64;
let v = ((p[1] - pmin[1]) / sy) as f64;
let wx = bl[0] + u * (br[0] - bl[0]) + v * (tl[0] - bl[0]);
let wy = bl[1] + u * (br[1] - bl[1]) + v * (tl[1] - bl[1]);
point(wx, wy)
};
if !ge.fill_tris.is_empty() {
// Filled TrueType glyph: one filled triangle per triple.
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
}),
});
for tri in ge.fill_tris.chunks_exact(3) {
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings: vec![PolygonRing {
points: tri
.iter()
.map(|&p| LinePoint {
p: map(p),
bezier: false,
})
.collect(),
}],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
},
});
}
} else {
// Stroke (LFF/SHX pen) font or hollow glyph: polylines.
// Match the SDF atlas' nominal glyph-space pen instead of
// borrowing the entity lineweight: Roman Duplex and similar
// multi-stroke faces rely on that band to close the narrow
// gaps between parallel centrelines. An explicit CTB
// lineweight still wins and stays absolute under the plot CTM.
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
}),
});
let pen = if let Some(ctb_pen) = lw_override {
if ge.bold {
ctb_pen * 1.7
} else {
ctb_pen
}
} else {
let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2))
.sqrt()
/ sy.abs() as f64) as f32;
(2.0 * sdf_atlas::stroke_pen_half_units(ge.bold) * glyph_unit_mm * MM_TO_PT)
.max(0.1)
};
ops.push(Op::SetOutlineThickness { pt: Pt(pen) });
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::*;
fn test_page(wires: Vec<PlotWire>) -> PdfPageInput {
PdfPageInput {
content: PlotContent {
wires: std::sync::Arc::new(wires),
..Default::default()
},
paper_w: 210.0,
paper_h: 297.0,
offset_x: 0.0,
offset_y: 0.0,
rotation_deg: 0,
scale: 1.0,
clip: None,
options: PdfPlotOptions::default(),
plot_style: None,
}
}
#[test]
fn atomic_write_replaces_an_existing_pdf() {
let path = std::env::temp_dir().join(format!(
"ocs-pdf-replace-{}-{}.pdf",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, b"old").unwrap();
write_pdf_atomically(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
std::fs::remove_file(path).unwrap();
}
#[test]
fn clip_and_scale_emit_pdf_bytes() {
let w = PlotWire {
wire: WireModel::solid(
"test".into(),
vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]],
WireModel::WHITE,
false,
),
draw_depth: 0.0,
};
let mut page = test_page(vec![w]);
page.scale = 2.0;
page.clip = Some((10.0, 10.0, 100.0, 100.0));
let bytes = render_pdf_bytes(&[page], None).unwrap();
// 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());
}
#[test]
fn downsample_target_returns_input_within_target() {
// Exactly at the cap keeps the native pixels…
assert_eq!(downsample_target(300, 150, 300.0, 300), (300, 150));
// …and so does drawing larger than the pixel count (factor > 1).
assert_eq!(downsample_target(300, 150, 150.0, 300), (300, 150));
}
#[test]
fn downsample_target_halves_two_x_oversampling() {
assert_eq!(downsample_target(600, 400, 600.0, 300), (300, 200));
}
#[test]
fn downsample_target_clamps_tiny_images_to_one_pixel() {
assert_eq!(downsample_target(2, 2, 1500.0, 150), (1, 1));
}
#[test]
fn plot_quality_dpi_caps_match_the_dialog_presets() {
assert_eq!(PlotQuality::High.target_dpi(), None);
assert_eq!(PlotQuality::Normal.target_dpi(), Some(300));
assert_eq!(PlotQuality::Low.target_dpi(), Some(150));
assert_eq!(PlotQuality::default(), PlotQuality::Normal);
}
#[test]
fn plot_quality_from_dialog_maps_legacy_and_unknown_names() {
assert_eq!(PlotQuality::from_dialog("Low"), PlotQuality::Low);
assert_eq!(PlotQuality::from_dialog("Draft"), PlotQuality::Low);
assert_eq!(PlotQuality::from_dialog("Normal"), PlotQuality::Normal);
assert_eq!(PlotQuality::from_dialog("High"), PlotQuality::High);
assert_eq!(PlotQuality::from_dialog("Presentation"), PlotQuality::High);
assert_eq!(PlotQuality::from_dialog("Maximum"), PlotQuality::High);
assert_eq!(PlotQuality::from_dialog("Best"), PlotQuality::Normal);
assert_eq!(PlotQuality::from_dialog(""), PlotQuality::Normal);
}
// A noise-filled square image drawn as a `paper_mm`-sized world quad.
// Incompressible pixels guarantee the High-quality PDF's embedded stream
// dominates the Low-quality one's after downsampling.
fn plot_image(px: u32, paper_mm: f32) -> PlotImage {
let mut pixels = Vec::with_capacity((px * px * 4) as usize);
for i in 0..px * px {
let v = (i.wrapping_mul(2_654_435_761) >> 24) as u8;
pixels.extend_from_slice(&[v, 255 - v, v ^ 0x5a, 0xff]);
}
let corner = |x: f32, y: f32| [x, y, 0.0];
let corners = [
corner(0.0, 0.0),
corner(paper_mm, 0.0),
corner(paper_mm, paper_mm),
corner(0.0, paper_mm),
];
let verts = [
(corners[0], [0.0, 1.0]),
(corners[1], [1.0, 1.0]),
(corners[2], [1.0, 0.0]),
(corners[0], [0.0, 1.0]),
(corners[2], [1.0, 0.0]),
(corners[3], [0.0, 0.0]),
]
.iter()
.map(
|(pos, uv)| crate::scene::model::image_model::ImageQuadVertex {
pos: *pos,
uv: *uv,
pos_low: [0.0; 3],
},
)
.collect();
PlotImage {
image: ImageModel {
render_instance: None,
file_path: String::new(),
pixels: std::sync::Arc::new(pixels),
width: px,
height: px,
opacity: 1.0,
corners,
corners_low: [[0.0; 3]; 4],
draw_depth: 0.5,
verts,
},
clips: Vec::new(),
}
}
#[test]
fn low_quality_embeds_a_smaller_image_stream() {
// 600 px on a 25.4 mm (1") square = 600 DPI effective; Low's 150 DPI
// cap resamples to 150×150 while High embeds the native pixels.
let image = plot_image(600, 25.4);
let mut high = test_page(Vec::new());
high.options.quality = PlotQuality::High;
high.content.images = vec![image.clone()];
let mut low = test_page(Vec::new());
low.options.quality = PlotQuality::Low;
low.content.images = vec![image];
let high_bytes = render_pdf_bytes(&[high], None).unwrap();
let low_bytes = render_pdf_bytes(&[low], None).unwrap();
assert!(high_bytes.starts_with(b"%PDF"));
assert!(
low_bytes.len() < high_bytes.len(),
"low quality did not shrink the PDF: {} !< {}",
low_bytes.len(),
high_bytes.len()
);
}
// Build a WireModel carrying the SDF glyph quads for `text` in the embedded
// "txt" stroke font, laid out into the process-wide atlas emit_text reads.
fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire {
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);
PlotWire {
wire: WireModel {
text_verts: verts,
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
},
draw_depth: 0.0,
}
}
// 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.wire.text_verts.clear();
let with_text = render_pdf_bytes(&[test_page(vec![wire])], None).unwrap();
let no_text = render_pdf_bytes(&[test_page(vec![blank])], None).unwrap();
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()
);
}
}