feat: plot a model-space window to PDF

Pick a rectangular window in model space with the existing PLOTWINDOW command (now enabled in model space, previously paper-space only) and export it to a PDF at a chosen ISO paper size (A4-A0), orientation and scale (fit or ratio). The window is a hard clip boundary. Adds paper-size presets, a window-to-sheet transform, scale + clip support in the PDF exporter, and Page Setup controls (format/orientation/pick window).
This commit is contained in:
sLuCHa committed 2026-07-05 23:13:43 +02:00
1 parent ced42daff5
commit 363593b09f
13 files changed
+444 -24

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ pub mod xref;
pub mod linetypes;
pub mod patterns;
pub mod update_check;
pub mod paper_sizes;
use crate::scene::DerivedCaches;
use acadrust::entities::EntityType;
+130
View File
@@ -0,0 +1,130 @@
//! ISO paper sizes and sheet orientation for window plotting.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PaperSize {
A4,
A3,
A2,
A1,
A0,
}
impl PaperSize {
pub const ALL: [PaperSize; 5] = [
PaperSize::A4,
PaperSize::A3,
PaperSize::A2,
PaperSize::A1,
PaperSize::A0,
];
pub fn label(self) -> &'static str {
match self {
PaperSize::A4 => "A4",
PaperSize::A3 => "A3",
PaperSize::A2 => "A2",
PaperSize::A1 => "A1",
PaperSize::A0 => "A0",
}
}
/// Portrait dimensions in mm (width, height); width < height.
pub fn dimensions_mm(self) -> (f64, f64) {
match self {
PaperSize::A4 => (210.0, 297.0),
PaperSize::A3 => (297.0, 420.0),
PaperSize::A2 => (420.0, 594.0),
PaperSize::A1 => (594.0, 841.0),
PaperSize::A0 => (841.0, 1189.0),
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Orientation {
Portrait,
Landscape,
}
/// Sheet dimensions in mm for the given size and orientation.
pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) {
let (w, h) = size.dimensions_mm();
match o {
Orientation::Portrait => (w, h),
Orientation::Landscape => (h, w),
}
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum PlotScale {
/// Scale so the window fills the sheet minus a 5% margin.
Fit,
/// Exact scale factor (mm per drawing unit).
Ratio(f64),
}
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y):
/// world (x, y) -> sheet mm ((x - window_min.0) * scale + offset_x, (y - window_min.1) * scale + offset_y).
/// The scaled window is centered on the sheet.
pub fn window_to_sheet(
window_wh: (f64, f64),
window_min: (f64, f64),
sheet_mm: (f64, f64),
scale: PlotScale,
) -> (f64, f64, f64) {
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
let scale = match scale {
PlotScale::Ratio(r) => r.max(1e-9),
PlotScale::Fit => {
const MARGIN: f64 = 1.05;
let sx = (sheet_mm.0 / MARGIN) / ww;
let sy = (sheet_mm.1 / MARGIN) / wh;
sx.min(sy)
}
};
let scaled_w = ww * scale;
let scaled_h = wh * scale;
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
let _ = window_min; // offset already places the window min at (offset_x, offset_y)
(scale, offset_x, offset_y)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_dimensions_and_orientation() {
assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0));
assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0));
assert_eq!(PaperSize::ALL.len(), 5);
assert_eq!(PaperSize::A3.label(), "A3");
// Portrait keeps (w,h); landscape swaps.
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0));
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0));
}
#[test]
fn fit_centers_and_scales_within_margin() {
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (0.0, 0.0), (210.0, 297.0), PlotScale::Fit);
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
// Window is 100*2 = 200 wide/tall; centered on 210×297.
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
#[test]
fn ratio_applies_exact_scale_and_offsets_window_min() {
// Ratio(0.5): scale is exactly 0.5; a window starting at (10,20)
// maps its min corner to the offset so (10,20) -> (ox,oy).
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (10.0, 20.0), (210.0, 297.0), PlotScale::Ratio(0.5));
assert!((s - 0.5).abs() < 1e-9);
// Centered: sheet_center - scaled_window_center, then min maps to offset.
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
}
+92 -13
View File
@@ -35,6 +35,8 @@ pub fn export_pdf(
_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> {
@@ -64,6 +66,8 @@ pub fn export_pdf(
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> {
@@ -76,6 +80,8 @@ pub fn export_pdf(
offset_x,
offset_y,
rotation_deg,
scale,
clip,
plot_style,
);
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
@@ -107,6 +113,8 @@ fn build_pdf(
ox: f32,
oy: f32,
rotation_deg: i32,
scale: f32,
clip: Option<(f32, f32, f32, f32)>,
plot_style: Option<&PlotStyleTable>,
) -> Vec<u8> {
let mut doc = PdfDocument::new("Open CAD Studio Export");
@@ -133,32 +141,67 @@ fn build_pdf(
join: LineJoinStyle::Round,
});
// Apply rotation transform if needed.
// Apply rotation/scale/clip transform if needed.
// PDF uses mm-based coordinate system with origin at bottom-left.
// We save state, apply a CTM, then restore it after drawing.
let needs_rotation = rotation_deg != 0;
if needs_rotation {
// 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 {
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),
};
// PDF CTM: [a b c d e f] = [cos sin -sin cos tx ty]
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 as f32,
sin_a as f32,
-(sin_a as f32),
cos_a as f32,
(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 {
const MM_TO_PT: f32 = 2.834645;
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,
},
});
}
}
// mm to PDF points (1 mm = 2.834645 pt).
@@ -203,7 +246,7 @@ fn build_pdf(
}
lw_override = ctb
.resolve_lineweight(wire.aci)
.map(|mm| (mm * MM_TO_PT).max(0.1));
.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
@@ -239,8 +282,13 @@ fn build_pdf(
last_color = Some([r, g, b]);
}
// Line weight: CTB override (in pt) or screen px → points.
let lw_pt = lw_override.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1));
// 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 (AutoCAD keeps lineweights independent of plot
// scale) — 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);
@@ -275,7 +323,7 @@ fn build_pdf(
flush_line(&mut ops, &segment);
}
if needs_rotation {
if needs_state {
ops.push(Op::RestoreGraphicsState);
}
@@ -440,3 +488,34 @@ fn emit_hatch(ops: &mut Vec<Op>, hatch: &HatchModel, ox: f32, oy: f32) {
},
});
}
#[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());
}
}
+2
View File
@@ -57,6 +57,8 @@ pub async fn print_wires(
offset_x,
offset_y,
rotation_deg,
1.0,
None,
&tmp_path,
plot_style.as_ref(),
)?;