Phase 7: Plot settings and PDF export

- Add printpdf 0.9.1 dependency for native PDF generation.
- New src/io/pdf_export.rs: converts paper-space WireModels to a PDF
  page (per-wire stroke color, line weight in Pt, NaN-split segments).
  White/near-white colors are inverted to black for print output.
- Add PlotExport / PlotExportPath messages: opens a save-file dialog
  and writes a .pdf next to the drawing file.
- Add PageSetupOpen/Close/WidthEdit/HeightEdit/Commit messages: a
  centered modal panel lets the user enter paper width and height (mm);
  on commit the Layout object's min/max_limits are updated in-place.
- Register PRINT / PLOT / EXPORT and PAGESETUP (PS) commands in
  dispatch_command; fix MVIEW error message to English.
- Layout ribbon gains a "Plot" group with Page Setup and Export PDF
  ribbon buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-03-31 08:05:17 +03:00
commit e1d296da10
9 changed files with 1639 additions and 140 deletions

1374
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,3 +14,4 @@ truck-polymesh = "0.6"
rfd = "0.15"
acadrust = "0.3.0"
open = "5"
printpdf = "0.9.1"

View file

@ -892,7 +892,7 @@ impl H7CAD {
// ── Layout / viewport ──────────────────────────────────────────
"MVIEW"|"MV" => {
if self.tabs[i].scene.current_layout == "Model" {
self.command_line.push_error("MVIEW: önce bir paper space layout'una geçin.");
self.command_line.push_error("MVIEW: switch to a paper space layout first.");
} else {
use crate::modules::layout::mview::MviewCommand;
let new_cmd = MviewCommand::new();
@ -901,6 +901,18 @@ impl H7CAD {
}
}
// ── Plot / Page Setup ──────────────────────────────────────────
"PRINT"|"PLOT"|"EXPORT" => {
return Task::done(Message::PlotExport);
}
"PAGESETUP"|"PS" => {
if self.tabs[i].scene.current_layout == "Model" {
self.command_line.push_error("PAGESETUP: switch to a paper space layout first.");
} else {
return Task::done(Message::PageSetupOpen);
}
}
_ => self.command_line.push_error(&format!("Unknown command: {cmd}")),
}

View file

@ -65,6 +65,12 @@ pub(super) struct H7CAD {
last_vp_click_time: Option<Instant>,
/// Screen position of the previous viewport left-click release.
last_vp_click_pos: Option<Point>,
/// Page Setup overlay open/closed.
page_setup_open: bool,
/// Editable paper width buffer for the Page Setup panel (string while typing).
page_setup_w: String,
/// Editable paper height buffer for the Page Setup panel (string while typing).
page_setup_h: String,
}
#[derive(Debug, Clone)]
@ -240,6 +246,22 @@ pub enum Message {
OsWindowClosed(window::Id),
/// No-op — used as a fallback when a TabEvent has no host mapping.
Noop,
// ── Page Setup ────────────────────────────────────────────────────────
/// Open the Page Setup panel for the current layout.
PageSetupOpen,
/// Close (cancel) the Page Setup panel without applying changes.
PageSetupClose,
/// Live-edit of the paper width field.
PageSetupWidthEdit(String),
/// Live-edit of the paper height field.
PageSetupHeightEdit(String),
/// Apply the changes entered in Page Setup.
PageSetupCommit,
// ── Plot / Export ─────────────────────────────────────────────────────
/// Show the SVG save-file dialog and trigger export.
PlotExport,
/// Callback after the user picks (or cancels) the export path.
PlotExportPath(Option<std::path::PathBuf>),
}
impl H7CAD {
@ -271,6 +293,9 @@ impl H7CAD {
layout_rename_state: None,
last_vp_click_time: None,
last_vp_click_pos: None,
page_setup_open: false,
page_setup_w: String::new(),
page_setup_h: String::new(),
};
app.sync_ribbon_layers();
app

View file

@ -1653,6 +1653,95 @@ impl H7CAD {
}
Message::Noop => Task::none(),
// ── Page Setup ────────────────────────────────────────────────
Message::PageSetupOpen => {
let i = self.active_tab;
// Populate edit buffers from current paper limits.
let (w, h) = if let Some(((_, _), (x1, y1))) = self.tabs[i].scene.paper_limits() {
(x1, y1)
} else {
(297.0, 210.0) // A4 default
};
self.page_setup_w = format!("{w:.1}");
self.page_setup_h = format!("{h:.1}");
self.page_setup_open = true;
Task::none()
}
Message::PageSetupClose => {
self.page_setup_open = false;
Task::none()
}
Message::PageSetupWidthEdit(s) => {
self.page_setup_w = s;
Task::none()
}
Message::PageSetupHeightEdit(s) => {
self.page_setup_h = s;
Task::none()
}
Message::PageSetupCommit => {
let i = self.active_tab;
let layout_name = self.tabs[i].scene.current_layout.clone();
if layout_name != "Model" {
let w: f64 = self.page_setup_w.parse::<f64>().unwrap_or(297.0).max(1.0);
let h: f64 = self.page_setup_h.parse::<f64>().unwrap_or(210.0).max(1.0);
// Update the Layout object's limits.
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(l) = obj {
if l.name == layout_name {
l.min_limits = (0.0, 0.0);
l.max_limits = (w, h);
l.min_extents = (0.0, 0.0, 0.0);
l.max_extents = (w, h, 0.0);
break;
}
}
}
self.tabs[i].dirty = true;
self.command_line.push_info(&format!(
"Page size set to {w:.1} × {h:.1} mm."
));
}
self.page_setup_open = false;
Task::none()
}
// ── Plot / Export ─────────────────────────────────────────────
Message::PlotExport => {
let i = self.active_tab;
let stem = self.tabs[i]
.current_path
.as_deref()
.and_then(|p: &std::path::Path| p.file_stem())
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
Task::perform(
crate::io::pdf_export::pick_pdf_path_owned(stem),
Message::PlotExportPath,
)
}
Message::PlotExportPath(None) => Task::none(),
Message::PlotExportPath(Some(path)) => {
let i = self.active_tab;
let wires = self.tabs[i].scene.entity_wires();
let (paper_w, paper_h) = if let Some(((_, _), (w, h))) =
self.tabs[i].scene.paper_limits()
{
(w, h)
} else {
// Model space: use extents or default A4 landscape.
(297.0, 210.0)
};
match crate::io::pdf_export::export_pdf(&wires, paper_w, paper_h, &path) {
Ok(()) => self.command_line.push_info(&format!(
"Exported: {}",
path.file_name().unwrap_or_default().to_string_lossy()
)),
Err(e) => self.command_line.push_error(&format!("Export failed: {e}")),
}
Task::none()
}
}
}
}

View file

@ -5,7 +5,7 @@ use super::helpers::grid_plane_from_camera;
use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD};
use crate::scene::grip::grips_to_screen;
use crate::ui::overlay;
use iced::widget::{button, column, container, mouse_area, row, shader, stack, text, Row};
use iced::widget::{button, column, container, mouse_area, row, shader, stack, text, text_input, Row};
use iced::window;
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};
@ -199,7 +199,13 @@ impl H7CAD {
iced::widget::Space::new().width(0).height(0).into()
};
stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer].into()
let page_setup_layer: Element<'_, Message> = if self.page_setup_open {
page_setup_overlay(&self.page_setup_w, &self.page_setup_h)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer].into()
}
pub fn subscription(&self) -> Subscription<Message> {
@ -443,3 +449,113 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> {
stack![catcher, positioned].into()
}
// ── Page Setup overlay ──────────────────────────────────────────────────────
/// Modal panel for editing paper width / height of the current layout.
fn page_setup_overlay<'a>(w_buf: &'a str, h_buf: &'a str) -> Element<'a, Message> {
const PANEL_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 };
const BORDER_COL: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 };
const TEXT_COLOR: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 };
const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 };
let label = |s: &'static str| text(s).size(12).color(TEXT_COLOR);
let field_style = |_: &Theme, _: text_input::Status| text_input::Style {
background: Background::Color(Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }),
border: Border { color: BORDER_COL, width: 1.0, radius: 3.0.into() },
icon: TEXT_COLOR,
placeholder: Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0 },
value: TEXT_COLOR,
selection: ACCENT,
};
let btn_style = |accent: bool| {
move |_: &Theme, status: button::Status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed if accent => {
Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 }
}
button::Status::Hovered | button::Status::Pressed => {
Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 }
}
_ if accent => ACCENT,
_ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 },
})),
text_color: TEXT_COLOR,
border: Border { color: BORDER_COL, width: 1.0, radius: 4.0.into() },
shadow: iced::Shadow::default(),
snap: false,
}
};
let panel = container(
column![
text("Page Setup").size(14).color(TEXT_COLOR),
container(iced::widget::Space::new().width(Fill).height(1))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER_COL)),
..Default::default()
})
.width(Fill),
row![
label("Width (mm)"),
text_input("297", w_buf)
.on_input(Message::PageSetupWidthEdit)
.on_submit(Message::PageSetupCommit)
.style(field_style)
.width(90)
.size(12),
]
.spacing(8)
.align_y(iced::Alignment::Center),
row![
label("Height (mm)"),
text_input("210", h_buf)
.on_input(Message::PageSetupHeightEdit)
.on_submit(Message::PageSetupCommit)
.style(field_style)
.width(90)
.size(12),
]
.spacing(8)
.align_y(iced::Alignment::Center),
row![
button(text("Cancel").size(12).color(TEXT_COLOR))
.on_press(Message::PageSetupClose)
.style(btn_style(false))
.padding([5, 14]),
button(text("OK").size(12).color(TEXT_COLOR))
.on_press(Message::PageSetupCommit)
.style(btn_style(true))
.padding([5, 20]),
]
.spacing(8),
]
.spacing(10)
.padding(16)
.width(240),
)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border { color: BORDER_COL, width: 1.0, radius: 6.0.into() },
..Default::default()
});
// Click-catcher to close on outside click.
let catcher = mouse_area(
container(iced::widget::Space::new().width(Fill).height(Fill))
.width(Fill)
.height(Fill),
)
.on_press(Message::PageSetupClose);
// Center the panel on screen.
let positioned = container(panel)
.width(Fill)
.height(Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center);
stack![catcher, positioned].into()
}

View file

@ -1,8 +1,10 @@
// I/O module — open and save CAD documents.
// I/O module — open, save, and export CAD documents.
//
// All file reading/writing goes through acadrust.
// Default save format: DWG (AC1032 / R2018+).
pub mod pdf_export;
use acadrust::io::dwg::DwgReader;
use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter};
use std::path::{Path, PathBuf};

127
src/io/pdf_export.rs Normal file
View file

@ -0,0 +1,127 @@
// 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 just pass the coordinates through directly.
use crate::scene::WireModel;
use printpdf::{Color, Line, LineCapStyle, LineJoinStyle, LinePoint, Mm, Op, PdfDocument,
PdfPage, PdfSaveOptions, Point, Pt, Rgb};
use std::io::Write;
use std::path::Path;
// ── Public entry point ────────────────────────────────────────────────────
/// Export `wires` to a PDF file.
///
/// `paper_w` / `paper_h` are in millimetres (drawing units assumed = mm).
pub fn export_pdf(
wires: &[WireModel],
paper_w: f64,
paper_h: f64,
path: &Path,
) -> Result<(), String> {
let bytes = build_pdf(wires, paper_w as f32, paper_h as f32);
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).
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| h.path().to_path_buf())
}
// ── PDF builder ───────────────────────────────────────────────────────────
fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32) -> Vec<u8> {
let mut doc = PdfDocument::new("H7CAD Export");
let mut ops: Vec<Op> = Vec::new();
// White page background rectangle.
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()),
});
// Round line caps for CAD aesthetics.
ops.push(Op::SetLineCapStyle { cap: LineCapStyle::Round });
ops.push(Op::SetLineJoinStyle { join: LineJoinStyle::Round });
let mut last_color: Option<[f32; 4]> = None;
let mut last_lw: Option<f32> = None;
for wire in wires {
// Set stroke color (white → black for print; skip fully transparent).
let [mut r, mut g, mut b, a] = wire.color;
if a < 0.01 {
continue;
}
// Invert near-white to black so it prints on white paper.
if r > 0.85 && g > 0.85 && b > 0.85 {
r = 0.0;
g = 0.0;
b = 0.0;
}
let color_changed = 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);
if color_changed {
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
});
last_color = Some([r, g, b, a]);
}
// Set line width (in points; 1 mm = 2.8346 pt).
let lw_pt = (wire.line_weight_px as f32 * 0.35278).max(0.1);
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);
}
// Collect segments (split at NaN).
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), Mm(y)),
bezier: false,
});
}
}
flush_line(&mut ops, &segment);
}
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)
}
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,
},
});
}

View file

@ -3,7 +3,7 @@
pub mod mview;
use crate::modules::{CadModule, RibbonGroup};
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, ToolDef};
pub struct LayoutModule;
@ -16,9 +16,30 @@ impl CadModule for LayoutModule {
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![RibbonGroup {
vec![
RibbonGroup {
title: "Viewport",
tools: vec![mview::tool().into()],
}]
},
RibbonGroup {
title: "Plot",
tools: vec![
ToolDef {
id: "PAGESETUP",
label: "Page Setup",
icon: IconKind::Glyph("📋"),
event: ModuleEvent::Command("PAGESETUP".to_string()),
}
.into(),
ToolDef {
id: "PLOT",
label: "Export PDF",
icon: IconKind::Glyph("🖨"),
event: ModuleEvent::Command("PLOT".to_string()),
}
.into(),
],
},
]
}
}