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:
parent
ace83a0953
commit
e1d296da10
9 changed files with 1639 additions and 140 deletions
1374
Cargo.lock
generated
1374
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,3 +14,4 @@ truck-polymesh = "0.6"
|
|||
rfd = "0.15"
|
||||
acadrust = "0.3.0"
|
||||
open = "5"
|
||||
printpdf = "0.9.1"
|
||||
|
|
|
|||
|
|
@ -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}")),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
120
src/app/view.rs
120
src/app/view.rs
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
127
src/io/pdf_export.rs
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue