feat(plot): unified plot/print dialog, retire Page Setup

Replace the old Page Setup panel with a single OCS-styled plot/print
dialog (dropdowns for printer, paper, orientation, rotation, plot area,
scale, quality, shaded plot; checkboxes for plot options). PLOT / PRINT
/ PAGESETUP all open it.

- Printer output via lp: -d printer, -n copies, -o ColorModel=Gray,
  print-quality, Resolution; Windows printto verb; list_printers via
  lpstat. Preview opens a temp PDF in the OS viewer (never a save
  dialog). PDF export reuses the tested pipeline.
- Window pick returns to the new dialog (was the old Page Setup) and the
  clipped window plot drives PDF / printer / preview.
- Remember the user's print preferences across sessions in
  config/plot.txt (printer, copies, quality, output options, area,
  scale); drawing-specific fields (offset/center/rotation) seed from the
  layout. Default plot area Window, scale Fit.
- Delete src/ui/window/page_setup.rs and its ModalKind / Message /
  buffer state; fold on_page_setup_commit into apply_plot_page_settings.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-09 10:27:54 +03:00
1 parent e9621d618b
commit 8820b43127
11 files changed
+1242 -660

No files matched your search

+5 -4
View File
@@ -1187,10 +1187,11 @@ impl OpenCADStudio {
self.command_line.push_output(&format!(
"Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}"
));
// Pick window closed Page Setup so the viewport could
// receive the two clicks — bring it back for scale
// review and the "Plot window → PDF" button.
self.active_modal = Some(super::ModalKind::PageSetup);
// Pick window closed the plot dialog so the viewport could
// receive the two clicks — bring the dialog back with the
// window now active.
self.plot_dialog.area = "Window".to_string();
self.active_modal = Some(super::ModalKind::Plot);
} else {
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let doc = &mut self.tabs[i].scene.document;
+8 -9
View File
@@ -358,12 +358,13 @@ impl OpenCADStudio {
}
// ── Plot / Page Setup ──────────────────────────────────────────
"PLOT" | "EXPORT" | "EXPORTPDF" => {
return Some(Task::done(Message::PlotExport));
// PLOT / PRINT open the full plot dialog (printer, paper, scale,
// options); EXPORT / EXPORTPDF stay a direct PDF export.
"PLOT" | "PRINT" => {
return Some(Task::done(Message::PlotDialogOpen));
}
// PRINT — send current layout to the system default printer.
"PRINT" => {
return Some(Task::done(Message::PrintToPrinter));
"EXPORT" | "EXPORTPDF" => {
return Some(Task::done(Message::PlotExport));
}
// PLOTSTYLE — load or clear CTB/STB plot style table
cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => {
@@ -509,11 +510,9 @@ impl OpenCADStudio {
}
}
// Model space now has its own dialog section (window-plot format /
// orientation / pick), so PAGESETUP opens there too, not just on
// paper-space layouts.
// PAGESETUP is folded into the unified plot dialog.
"PAGESETUP" => {
return Some(Task::done(Message::PageSetupOpen));
return Some(Task::done(Message::PlotDialogOpen));
}
// ── Recognized commands whose full implementation is pending ─────────
+11 -49
View File
@@ -513,26 +513,14 @@ pub(super) struct OpenCADStudio {
last_vp_click_time: Option<Instant>,
/// Screen position of the previous viewport left-click release.
last_vp_click_pos: Option<Point>,
/// 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,
/// Plot area type: "Layout" | "Extents".
page_setup_plot_area: String,
/// Center the drawing on the page when exporting.
page_setup_center: bool,
/// Plot offset X in mm (applied after optional centering).
page_setup_offset_x: String,
/// Plot offset Y in mm.
page_setup_offset_y: String,
/// Plot rotation in degrees: "0" | "90" | "180" | "270".
page_setup_rotation: String,
/// Plot scale: "Fit" | "1:1" | "1:2" | "1:4" | "1:5" | "1:10" | "1:20" | "1:50" | "1:100" | "2:1".
page_setup_scale: String,
/// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1".
plot_scale: String,
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
plot_window: Option<(f64, f64, f64, f64)>,
plot_format: crate::io::paper_sizes::PaperSize,
plot_orientation: crate::io::paper_sizes::Orientation,
/// Backing state for the full Plot / Print dialog.
plot_dialog: crate::ui::window::plot::PlotDialogState,
// ── Plot Style Table ──────────────────────────────────────────────────
/// Currently loaded CTB/STB table (None = no override).
@@ -1058,7 +1046,7 @@ pub enum ModalKind {
PluginManager,
UpdateNotice,
Layers,
PageSetup,
Plot,
LayoutManager,
Plotstyle,
TextStyle,
@@ -1791,30 +1779,6 @@ pub enum Message {
/// User clicked the "Open release page" button — opens the GitHub
/// release URL in the OS default browser and closes the notice.
UpdateNoticeOpenRelease,
// ── 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),
/// User selected a paper size preset (e.g. "A4 Portrait").
PageSetupPreset(String),
/// User changed the plot area type ("Layout" or "Extents").
PageSetupPlotArea(String),
/// Toggle center-on-page.
PageSetupCenterToggle,
/// Live-edit of plot offset X.
PageSetupOffsetXEdit(String),
/// Live-edit of plot offset Y.
PageSetupOffsetYEdit(String),
/// User changed plot rotation.
PageSetupRotation(String),
PageSetupScale(String),
/// Apply the changes entered in Page Setup.
PageSetupCommit,
// ── Plot / Export ─────────────────────────────────────────────────────
/// Show the SVG save-file dialog and trigger export.
PlotExport,
@@ -1832,6 +1796,10 @@ pub enum Message {
PrintToPrinter,
/// Callback from the async printer job.
PrintResult(Result<String, String>),
/// Open the full Plot / Print dialog (seeds state from the layout).
PlotDialogOpen,
/// An edit inside the Plot / Print dialog.
PlotDlg(crate::ui::window::plot::PlotDlgMsg),
// ── Plot Style Table ─────────────────────────────────────────────────
/// Open file dialog to load a CTB/STB plot style table.
PlotStyleLoad,
@@ -2167,17 +2135,11 @@ impl OpenCADStudio {
layout_rename_state: None,
last_vp_click_time: None,
last_vp_click_pos: None,
page_setup_w: String::new(),
page_setup_h: String::new(),
page_setup_plot_area: "Layout".to_string(),
page_setup_center: true,
page_setup_offset_x: "0.0".to_string(),
page_setup_offset_y: "0.0".to_string(),
page_setup_rotation: "0".to_string(),
page_setup_scale: "Fit".to_string(),
plot_scale: "Fit".to_string(),
plot_window: None,
plot_format: crate::io::paper_sizes::PaperSize::A4,
plot_orientation: crate::io::paper_sizes::Orientation::Landscape,
plot_dialog: crate::ui::window::plot::PlotDialogState::load(),
opening: None,
pending_close: None,
save_dialog_format: "DWG 2018".to_string(),
+455 -75
View File
@@ -808,18 +808,28 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
iced::exit()
}
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
/// Write the given plot page settings into the active layout's Layout +
/// PlotSettings objects (paper size, plot area, offset, rotation, scale).
/// No-op on the Model tab (which has no paper layout). Marks the tab dirty
/// and re-tessellates the sheet. Called by the Plot dialog on commit.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_plot_page_settings(
&mut self,
w: f64,
h: f64,
extents: bool,
center: bool,
offset_x: f64,
offset_y: f64,
rotation: i16,
scale_str: &str,
) {
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);
let plot_area = self.page_setup_plot_area.clone();
let center = self.page_setup_center;
let offset_x = self.page_setup_offset_x.parse::<f64>().unwrap_or(0.0);
let offset_y = self.page_setup_offset_y.parse::<f64>().unwrap_or(0.0);
let rotation: i16 = self.page_setup_rotation.parse().unwrap_or(0);
let scale_str = self.page_setup_scale.clone();
let w: f64 = w.max(1.0);
let h: f64 = h.max(1.0);
let plot_area = if extents { "Extents" } else { "Layout" };
// Update the Layout object's limits AND its embedded
// PlotSettings fields. `paper_limits()` (sheet rendering) and
@@ -905,7 +915,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
};
// Apply plot scale.
use acadrust::objects::ScaledType;
let (num, den) = parse_plot_scale(&scale_str);
let (num, den) = parse_plot_scale(scale_str);
if scale_str == "Fit" {
ps.set_scale_to_fit();
} else {
@@ -924,8 +934,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
center={center} rot={rotation}°"
));
}
self.active_modal = None;
Task::none()
}
pub(super) fn on_plot_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
@@ -1062,10 +1070,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation);
let win_w = (x1 - x0).max(1e-9);
let win_h = (y1 - y0).max(1e-9);
let scale_sel = if self.page_setup_scale.trim().eq_ignore_ascii_case("fit") {
let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") {
PlotScale::Fit
} else {
let (num, den) = parse_plot_scale(&self.page_setup_scale);
let (num, den) = parse_plot_scale(&self.plot_scale);
if num > 0.0 && den > 0.0 {
PlotScale::Ratio(num / den)
} else {
@@ -1126,70 +1134,442 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
Task::none()
}
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let wires = scene.entity_wires();
let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone();
let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone();
use acadrust::objects::PlotType;
let ps_snap = scene.effective_plot_settings();
let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) =
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
let (pw, ph) = (x1 - x0, y1 - y0);
let use_extents = ps_snap
.as_ref()
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
.unwrap_or(false);
let (ox, oy) = if use_extents {
if let Some((mn, _mx)) = scene.model_space_extents() {
(-mn.x as f64, -mn.y as f64)
} else {
(-x0, -y0)
}
} else {
(-x0, -y0)
};
let rot = ps_snap
.as_ref()
.map(|ps| ps.rotation.to_degrees() as i32)
.unwrap_or(0);
(pw, ph, ox, oy, rot)
/// Build the render inputs and page geometry for a full-layout plot: wires
/// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size,
/// the draw-origin offset, and the rotation. Shared by print + preview so
/// the Plot dialog reuses the same tested derivation.
#[allow(clippy::type_complexity)]
pub(super) fn layout_plot_params(
&self,
) -> (
Vec<crate::scene::WireModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
f64,
f64,
f32,
f32,
i32,
) {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let wires = scene.entity_wires();
let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone();
let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone();
use acadrust::objects::PlotType;
let ps_snap = scene.effective_plot_settings();
let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) =
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
let (pw, ph) = (x1 - x0, y1 - y0);
let use_extents = ps_snap
.as_ref()
.map(|ps| matches!(ps.plot_type, PlotType::Extents))
.unwrap_or(false);
let (ox, oy) = if use_extents {
if let Some((mn, _mx)) = scene.model_space_extents() {
(-mn.x as f64, -mn.y as f64)
} else {
if let Some((mn, mx)) = scene.model_space_extents() {
let margin = 1.05_f64;
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
} else {
(297.0, 210.0, 0.0, 0.0, 0)
}
};
let (eff_w, eff_h) = match rotation_deg {
90 | 270 => (paper_h, paper_w),
_ => (paper_w, paper_h),
(-x0, -y0)
}
} else {
(-x0, -y0)
};
let plot_style = self.active_plot_style.clone();
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
crate::io::print_to_printer::print_wires(
wires,
hatches,
wipeouts,
eff_w,
eff_h,
draw_ox as f32,
draw_oy as f32,
rotation_deg,
plot_style,
)
.await
},
Message::PrintResult,
let rot = ps_snap
.as_ref()
.map(|ps| ps.rotation.to_degrees() as i32)
.unwrap_or(0);
(pw, ph, ox, oy, rot)
} else if let Some((mn, mx)) = scene.model_space_extents() {
let margin = 1.05_f64;
let w = ((mx.x - mn.x) as f64 * margin).max(1.0);
let h = ((mx.y - mn.y) as f64 * margin).max(1.0);
let pad_x = (w - (mx.x - mn.x) as f64) * 0.5;
let pad_y = (h - (mx.y - mn.y) as f64) * 0.5;
(w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0)
} else {
(297.0, 210.0, 0.0, 0.0, 0)
};
let (eff_w, eff_h) = match rotation_deg {
90 | 270 => (paper_h, paper_w),
_ => (paper_w, paper_h),
};
(
wires,
hatches,
wipeouts,
eff_w,
eff_h,
draw_ox as f32,
draw_oy as f32,
rotation_deg,
)
}
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg) =
self.layout_plot_params();
let plot_style = self.active_plot_style.clone();
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
crate::io::print_to_printer::print_wires(
wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg, plot_style,
)
.await
},
Message::PrintResult,
)
}
/// Open the full Plot / Print dialog, seeding its state from the active
/// layout's plot settings and the printers found on the system.
pub(super) fn on_plot_dialog_open(&mut self) -> Task<Message> {
use crate::io::paper_sizes::Orientation;
// Keep the user's last print preferences (printer, copies, quality,
// output options — persisted in `self.plot_dialog`); only refresh the
// runtime printer list and reseed the drawing-specific fields from the
// active layout.
let d = &mut self.plot_dialog;
d.printers = crate::io::print_to_printer::list_printers();
d.paper = self.plot_format.label().to_string();
d.orientation = match self.plot_orientation {
Orientation::Portrait => "Portrait",
Orientation::Landscape => "Landscape",
}
.to_string();
d.style_name = self
.active_plot_style
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_default();
// `area` and `scale` are remembered user choices (default Window / Fit),
// not reseeded from the layout. Offset / center / rotation ARE layout
// properties, so reflect them.
if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() {
let d = &mut self.plot_dialog;
d.center = ps.flags.plot_centered;
d.offset_x = format!("{:.2}", ps.origin_x);
d.offset_y = format!("{:.2}", ps.origin_y);
let deg = ps.rotation.to_degrees() as i32;
d.rotation = format!("{deg}°");
}
self.active_modal = Some(crate::app::ModalKind::Plot);
Task::none()
}
/// Handle one edit / action from the Plot dialog.
pub(super) fn on_plot_dlg(
&mut self,
msg: crate::ui::window::plot::PlotDlgMsg,
) -> Task<Message> {
use crate::ui::window::plot::{PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF};
match msg {
M::Close => {
self.close_active_modal();
Task::none()
}
M::Printer(s) => {
if s == OUT_PDF {
self.plot_dialog.to_file = true;
} else if s == OUT_DEFAULT {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = None;
} else {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = Some(s);
}
Task::none()
}
M::Paper(s) => {
self.plot_dialog.paper = s;
Task::none()
}
M::Orientation(s) => {
self.plot_dialog.orientation = s;
Task::none()
}
M::Rotation(s) => {
self.plot_dialog.rotation = s;
Task::none()
}
M::Area(s) => {
self.plot_dialog.area = s;
Task::none()
}
M::Scale(s) => {
self.plot_dialog.scale = s;
Task::none()
}
M::Quality(s) => {
self.plot_dialog.quality = s;
Task::none()
}
M::Shade(s) => {
self.plot_dialog.shade = s;
Task::none()
}
M::Copies(s) => {
self.plot_dialog.copies = s;
Task::none()
}
M::OffsetX(s) => {
self.plot_dialog.offset_x = s;
Task::none()
}
M::OffsetY(s) => {
self.plot_dialog.offset_y = s;
Task::none()
}
M::Dpi(s) => {
self.plot_dialog.dpi = s;
Task::none()
}
M::Flag(f) => {
let d = &mut self.plot_dialog;
match f {
PlotFlag::Center => d.center = !d.center,
PlotFlag::ScaleLw => d.scale_lw = !d.scale_lw,
PlotFlag::Mono => d.mono = !d.mono,
PlotFlag::Lineweights => d.lineweights = !d.lineweights,
PlotFlag::WithStyles => d.with_styles = !d.with_styles,
PlotFlag::Transparency => d.transparency = !d.transparency,
PlotFlag::PaperspaceLast => d.paperspace_last = !d.paperspace_last,
PlotFlag::HidePaperspace => d.hide_paperspace = !d.hide_paperspace,
PlotFlag::Stamp => d.stamp = !d.stamp,
PlotFlag::SaveLayout => d.save_layout = !d.save_layout,
}
Task::none()
}
M::LoadStyle => Task::done(Message::PlotStyleLoad),
M::ClearStyle => {
self.active_plot_style = None;
self.plot_dialog.style_name.clear();
Task::none()
}
M::PickWindow => {
self.close_active_modal();
Task::done(Message::Command("PLOTWINDOW".into()))
}
M::Preview => self.on_plot_dlg_commit(true),
M::Commit => self.on_plot_dlg_commit(false),
}
}
/// Apply the dialog's page settings to the layout, then either open a
/// preview PDF, export a PDF, or send the job to the chosen printer.
fn on_plot_dlg_commit(&mut self, preview: bool) -> Task<Message> {
use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize};
// Remember the user's print preferences across sessions.
self.plot_dialog.save();
let d = self.plot_dialog.clone();
let paper = match d.paper.as_str() {
"A3" => PaperSize::A3,
"A2" => PaperSize::A2,
"A1" => PaperSize::A1,
"A0" => PaperSize::A0,
_ => PaperSize::A4,
};
let orient = if d.orientation == "Portrait" {
Orientation::Portrait
} else {
Orientation::Landscape
};
let (sheet_w, sheet_h) = sheet_mm(paper, orient);
// Persist the dialog's page settings into the layout's PlotSettings /
// paper_limits, then reuse the tested layout-plot derivation. `plot_scale`
// feeds the model-space window plot path.
self.plot_format = paper;
self.plot_orientation = orient;
self.plot_scale = d.scale.clone();
let rotation: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0);
let off_x = d.offset_x.parse::<f64>().unwrap_or(0.0);
let off_y = d.offset_y.parse::<f64>().unwrap_or(0.0);
let extents = d.area != "Layout";
self.apply_plot_page_settings(
sheet_w, sheet_h, extents, d.center, off_x, off_y, rotation, &d.scale,
);
self.active_modal = None;
let plot_style = if d.with_styles {
self.active_plot_style.clone()
} else {
None
};
// ── Window area: clipped model-space plot ────────────────────────────
if d.area == "Window" {
// Preview renders to a temp PDF and opens it — never a save dialog,
// whatever the output target is.
if preview {
let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) =
self.window_plot_job()
else {
self.command_line
.push_error("Pick a plot window first (model space).");
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
};
let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf");
let exp = crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp,
plot_style.as_ref(),
);
match exp.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) {
Ok(()) => self.command_line.push_info("Opened plot preview."),
Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")),
}
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
}
if d.to_file {
// Tested clipped export (opens a save dialog).
return Task::done(Message::PlotWindowExport);
}
let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) =
self.window_plot_job()
else {
self.command_line
.push_error("Pick a plot window first (model space).");
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
};
let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf");
let exp = crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp,
plot_style.as_ref(),
);
let opts = self.plot_print_options(&d);
match exp.and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) {
Ok(printer) => self
.command_line
.push_info(&format!("Sent to printer: {printer}")),
Err(e) => self.command_line.push_error(&format!("Print failed: {e}")),
}
return Task::none();
}
let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot) = self.layout_plot_params();
if preview {
let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf");
let res = crate::io::pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
eff_w,
eff_h,
ox,
oy,
rot,
1.0,
None,
&tmp,
plot_style.as_ref(),
);
match res.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) {
Ok(()) => self.command_line.push_info("Opened plot preview."),
Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")),
}
// Preview leaves the dialog open for further tweaks.
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
}
if d.to_file {
// Reuse the tested PDF export flow (it opens a save dialog).
return Task::done(Message::PlotExport);
}
let opts = self.plot_print_options(&d);
self.command_line.push_info("Sending to system printer…");
Task::perform(
async move {
crate::io::print_to_printer::print_wires_with(
wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot, plot_style, opts,
)
.await
},
Message::PrintResult,
)
}
/// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from
/// the dialog state.
fn plot_print_options(
&self,
d: &crate::ui::window::plot::PlotDialogState,
) -> crate::io::print_to_printer::PrintOptions {
crate::io::print_to_printer::PrintOptions {
printer: d.printer.clone(),
copies: d.copies.trim().parse::<u32>().unwrap_or(1).max(1),
mono: d.mono,
quality: Some(d.quality.clone()),
dpi: d.dpi.trim().parse::<u32>().ok().filter(|v| *v > 0),
}
}
/// Render inputs + page geometry for a clipped model-space window plot
/// (the pending `plot_window`, fitted onto the dialog's sheet). Returns
/// `None` when not in model space or no window has been picked. Mirrors
/// [`Self::on_plot_window_export_path_some`] but hands back the params so
/// the plot can go to a printer or preview, not only a save-dialog PDF.
#[allow(clippy::type_complexity)]
fn window_plot_job(
&self,
) -> Option<(
Vec<crate::scene::WireModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
f64,
f64,
f32,
f32,
f32,
Option<(f32, f32, f32, f32)>,
)> {
use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale};
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model" {
return None;
}
let (x0, y0, x1, y1) = self.plot_window?;
if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 {
return None;
}
let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation);
let win_w = (x1 - x0).max(1e-9);
let win_h = (y1 - y0).max(1e-9);
let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") {
PlotScale::Fit
} else {
let (num, den) = parse_plot_scale(&self.plot_scale);
if num > 0.0 && den > 0.0 {
PlotScale::Ratio(num / den)
} else {
PlotScale::Fit
}
};
let (scale, ox, oy) = window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel);
let scene = &self.tabs[i].scene;
let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32);
let wires: Vec<_> = scene
.entity_wires()
.into_iter()
.filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0)
.collect();
let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone();
let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone();
let offset_x = ((ox / scale) - x0) as f32;
let offset_y = ((oy / scale) - y0) as f32;
let clip = Some((
(ox / scale) as f32,
(oy / scale) as f32,
win_w as f32,
win_h as f32,
));
Some((
wires, hatches, wipeouts, sheet_w, sheet_h, offset_x, offset_y, scale as f32, clip,
))
}
pub(super) fn on_plot_style_panel_apply(&mut self) -> Task<Message> {
+7 -73
View File
@@ -79,7 +79,11 @@ impl OpenCADStudio {
}
match self.active_modal {
Some(Layers) => self.ribbon.deactivate_tool_if("LAYERS"),
Some(PageSetup) => self.ribbon.deactivate_tool_if("PAGESETUP"),
Some(Plot) => {
self.ribbon.deactivate_tool_if("PLOT");
self.ribbon.deactivate_tool_if("PRINT");
self.ribbon.deactivate_tool_if("PAGESETUP");
}
Some(TextStyle) => {
self.ribbon.deactivate_tool_if("STYLE");
self.ribbon.deactivate_tool_if("TEXTSTYLE");
@@ -3020,23 +3024,6 @@ impl OpenCADStudio {
}
// ── Page Setup ────────────────────────────────────────────────
Message::PageSetupOpen => {
let i = self.active_tab;
// Populate edit buffers from current paper limits.
let (w, h) = if let Some(((x0, y0), (x1, y1))) = self.tabs[i].scene.paper_limits() {
(x1 - x0, y1 - y0)
} else {
(297.0, 210.0) // A4 default
};
self.page_setup_w = format!("{w:.1}");
self.page_setup_h = format!("{h:.1}");
self.active_modal = Some(super::ModalKind::PageSetup);
Task::none()
}
Message::PageSetupClose => {
self.close_active_modal();
Task::none()
}
Message::UpdateCheckResult(latest) => {
let Some(info) = latest else {
return Task::none();
@@ -3081,61 +3068,8 @@ impl OpenCADStudio {
}
Task::none()
}
Message::PageSetupWidthEdit(s) => {
self.page_setup_w = s;
Task::none()
}
Message::PageSetupHeightEdit(s) => {
self.page_setup_h = s;
Task::none()
}
Message::PageSetupPreset(name) => {
// Paper size presets defined in view.rs — mirror them here.
let sizes: &[(&str, f64, f64)] = &[
("A4 Portrait", 210.0, 297.0),
("A4 Landscape", 297.0, 210.0),
("A3 Portrait", 297.0, 420.0),
("A3 Landscape", 420.0, 297.0),
("A2 Portrait", 420.0, 594.0),
("A2 Landscape", 594.0, 420.0),
("A1 Portrait", 594.0, 841.0),
("A1 Landscape", 841.0, 594.0),
("A0 Portrait", 841.0, 1189.0),
("A0 Landscape", 1189.0, 841.0),
("Letter Portrait", 215.9, 279.4),
("Letter Landscape", 279.4, 215.9),
];
if let Some(&(_, w, h)) = sizes.iter().find(|(n, _, _)| *n == name) {
self.page_setup_w = format!("{w:.1}");
self.page_setup_h = format!("{h:.1}");
}
Task::none()
}
Message::PageSetupPlotArea(s) => {
self.page_setup_plot_area = s;
Task::none()
}
Message::PageSetupCenterToggle => {
self.page_setup_center = !self.page_setup_center;
Task::none()
}
Message::PageSetupOffsetXEdit(s) => {
self.page_setup_offset_x = s;
Task::none()
}
Message::PageSetupOffsetYEdit(s) => {
self.page_setup_offset_y = s;
Task::none()
}
Message::PageSetupRotation(s) => {
self.page_setup_rotation = s;
Task::none()
}
Message::PageSetupScale(s) => {
self.page_setup_scale = s;
Task::none()
}
Message::PageSetupCommit => self.on_page_setup_commit(),
Message::PlotDialogOpen => self.on_plot_dialog_open(),
Message::PlotDlg(m) => self.on_plot_dlg(m),
// ── Plot / Export ─────────────────────────────────────────────
Message::PlotExport => {
+1 -1
View File
@@ -1466,7 +1466,7 @@ impl OpenCADStudio {
PluginManager => (520, 460),
UpdateNotice => (560, 460),
Layers => (900, 360),
PageSetup => (520, 460),
Plot => (660, 540),
LayoutManager => (640, 320),
Plotstyle => (780, 540),
TextStyle => (860, 480),
+3 -16
View File
@@ -47,22 +47,9 @@ impl OpenCADStudio {
let tab = &self.tabs[self.active_tab];
sized(tab.layers.view_window(), 900, 360)
}
super::super::ModalKind::PageSetup => sized(
crate::ui::window::page_setup::view_window(
&self.page_setup_w,
&self.page_setup_h,
&self.page_setup_plot_area,
self.page_setup_center,
&self.page_setup_offset_x,
&self.page_setup_offset_y,
&self.page_setup_rotation,
&self.page_setup_scale,
self.plot_format,
self.plot_orientation,
),
520,
460,
),
super::super::ModalKind::Plot => {
sized(crate::ui::window::plot::view_window(&self.plot_dialog), 660, 540)
}
super::super::ModalKind::LayoutManager => {
let i = self.active_tab;
let layouts = self.tabs[i].scene.layout_names();
+230
View File
@@ -13,6 +13,24 @@ use crate::io::plot_style::PlotStyleTable;
use crate::scene::model::hatch_model::HatchModel;
use crate::scene::WireModel;
/// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp`
/// flags / `-o` options. On Windows only `printer` is honoured (via the
/// "printto" shell verb); copies / quality / colour need the driver DEVMODE
/// and are ignored there.
#[derive(Debug, Clone, Default)]
pub struct PrintOptions {
/// Target printer name, or `None` for the system default.
pub printer: Option<String>,
/// Number of copies (treated as at least 1).
pub copies: u32,
/// Force grayscale output.
pub mono: bool,
/// Print quality: "Draft" | "Normal" | "High" | "Maximum".
pub quality: Option<String>,
/// Rasterisation resolution in DPI.
pub dpi: Option<u32>,
}
// Printing routes through the native PDF pipeline + the OS print command, so it
// is native-only; the web build gets a stub so the call site still compiles.
#[cfg(target_arch = "wasm32")]
@@ -30,6 +48,38 @@ pub async fn print_wires(
Err("Printing is not available in the web version.".into())
}
#[cfg(target_arch = "wasm32")]
pub fn list_printers() -> Vec<String> {
Vec::new()
}
#[cfg(target_arch = "wasm32")]
#[allow(clippy::too_many_arguments)]
pub async fn print_wires_with(
_wires: Vec<WireModel>,
_hatches: Vec<HatchModel>,
_wipeouts: Vec<HatchModel>,
_paper_w: f64,
_paper_h: f64,
_offset_x: f32,
_offset_y: f32,
_rotation_deg: i32,
_plot_style: Option<PlotStyleTable>,
_opts: PrintOptions,
) -> Result<String, String> {
Err("Printing is not available in the web version.".into())
}
#[cfg(target_arch = "wasm32")]
pub fn open_in_viewer(_path: &std::path::Path) -> Result<(), String> {
Err("Preview is not available in the web version.".into())
}
#[cfg(target_arch = "wasm32")]
pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Result<String, String> {
Err("Printing is not available in the web version.".into())
}
/// Render `wires` (plus hatch / wipeout fills) to a temp PDF and dispatch it
/// to the default system printer.
///
@@ -67,6 +117,186 @@ pub async fn print_wires(
dispatch_to_printer(&tmp_path)
}
/// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`;
/// Windows returns an empty list (the "printto" dispatch targets a named
/// printer directly and the system default is always available).
#[cfg(not(target_arch = "wasm32"))]
pub fn list_printers() -> Vec<String> {
#[cfg(not(target_os = "windows"))]
{
std::process::Command::new("lpstat")
.arg("-e")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
})
.unwrap_or_default()
}
#[cfg(target_os = "windows")]
{
Vec::new()
}
}
/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies,
/// grayscale, quality, DPI).
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
pub async fn print_wires_with(
wires: Vec<WireModel>,
hatches: Vec<HatchModel>,
wipeouts: Vec<HatchModel>,
paper_w: f64,
paper_h: f64,
offset_x: f32,
offset_y: f32,
rotation_deg: i32,
plot_style: Option<PlotStyleTable>,
opts: PrintOptions,
) -> Result<String, String> {
let tmp_path = std::env::temp_dir().join("open_cad_studio_print.pdf");
pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
1.0,
None,
&tmp_path,
plot_style.as_ref(),
)?;
dispatch_to_printer_opts(&tmp_path, &opts)
}
/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for
/// clipped window plots, whose PDF is built with a scale + clip the plain
/// `print_wires_with` path doesn't expose.
#[cfg(not(target_arch = "wasm32"))]
pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
dispatch_to_printer_opts(path, opts)
}
/// Open a file with the OS default application (used for print preview).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
let p = path.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", "", &p]);
c
};
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = std::process::Command::new("open");
c.arg(&p);
c
};
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let mut cmd = {
let mut c = std::process::Command::new("xdg-open");
c.arg(&p);
c
};
cmd.spawn()
.map(|_| ())
.map_err(|e| format!("Could not open preview: {e}"))
}
/// Dispatch a PDF to a specific printer with [`PrintOptions`].
#[cfg(not(target_arch = "wasm32"))]
fn dispatch_to_printer_opts(
path: &std::path::Path,
opts: &PrintOptions,
) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
// Target a named printer via the "printto" verb; fall back to the
// default-printer "print" verb. Copies / quality / colour aren't
// expressible through a shell verb, so they are ignored here.
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let wide = |s: &str| -> Vec<u16> { OsStr::new(s).encode_wide().chain(Some(0)).collect() };
let path_wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let (verb, params, label) = match opts.printer.as_deref() {
Some(p) if !p.is_empty() => (wide("printto"), Some(wide(p)), p.to_string()),
_ => (wide("print"), None, "default printer".to_string()),
};
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
let result = unsafe {
windows_sys::Win32::UI::Shell::ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
path_wide.as_ptr(),
params_ptr,
std::ptr::null(),
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
) as usize
};
if result > 32 {
Ok(label)
} else {
Err(format!("ShellExecute failed (code {result})"))
}
}
#[cfg(not(target_os = "windows"))]
{
let path_str = path.to_string_lossy();
let mut cmd = std::process::Command::new("lp");
if let Some(p) = opts.printer.as_deref() {
if !p.is_empty() {
cmd.arg("-d").arg(p);
}
}
let copies = opts.copies.max(1);
if copies > 1 {
cmd.arg("-n").arg(copies.to_string());
}
if opts.mono {
cmd.arg("-o").arg("ColorModel=Gray");
}
if let Some(dpi) = opts.dpi {
cmd.arg("-o").arg(format!("Resolution={dpi}dpi"));
}
if let Some(q) = opts.quality.as_deref() {
// CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best.
let pq = match q {
"Draft" => "3",
"High" | "Maximum" => "5",
_ => "4",
};
cmd.arg("-o").arg(format!("print-quality={pq}"));
}
let out = cmd
.arg("--")
.arg(path_str.as_ref())
.output()
.map_err(|e| format!("Could not launch lp: {e}"))?;
if out.status.success() {
let msg = String::from_utf8_lossy(&out.stdout);
let printer = msg
.split_whitespace()
.find(|w| w.contains('-'))
.unwrap_or("printer")
.to_string();
Ok(printer)
} else {
Err(String::from_utf8_lossy(&out.stderr).into_owned())
}
}
}
/// Platform-specific dispatch of a PDF path to the system printer.
#[cfg(not(target_arch = "wasm32"))]
fn dispatch_to_printer(path: &std::path::Path) -> Result<String, String> {
+1 -1
View File
@@ -1,6 +1,6 @@
pub mod about;
pub mod layout_manager;
pub mod page_setup;
pub mod plot;
pub mod plugin_manager;
pub mod shortcuts;
pub mod layers;
-432
View File
@@ -1,432 +0,0 @@
//! Page Setup window — fills the entire OS window.
use crate::app::Message;
use crate::io::paper_sizes::{Orientation, PaperSize};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
const TB: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const BORDER: Color = Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
};
const TEXT: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
const DIM: Color = Color {
r: 0.55,
g: 0.55,
b: 0.55,
a: 1.0,
};
const ACCENT: Color = Color {
r: 0.25,
g: 0.50,
b: 0.85,
a: 1.0,
};
const ACTIVE: Color = Color {
r: 0.20,
g: 0.40,
b: 0.70,
a: 1.0,
};
const FIELD: Color = Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 1.0,
};
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (accent, st) {
(true, button::Status::Hovered | button::Status::Pressed) => Color {
r: 0.20,
g: 0.42,
b: 0.72,
a: 1.0,
},
(false, button::Status::Hovered | button::Status::Pressed) => Color {
r: 0.28,
g: 0.28,
b: 0.28,
a: 1.0,
},
(true, _) => ACCENT,
_ => Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
},
})),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
}
fn pill(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (active, st) {
(true, _) => ACTIVE,
(false, button::Status::Hovered | button::Status::Pressed) => Color {
r: 0.28,
g: 0.28,
b: 0.28,
a: 1.0,
},
_ => Color {
r: 0.20,
g: 0.20,
b: 0.20,
a: 1.0,
},
})),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
}
fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style {
text_input::Style {
background: Background::Color(FIELD),
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
icon: TEXT,
placeholder: DIM,
value: TEXT,
selection: ACCENT,
}
}
fn hdivider<'a>() -> Element<'a, Message> {
container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
})
.into()
}
fn section_label<'a>(s: &'static str) -> Element<'a, Message> {
text(s).size(11).color(DIM).into()
}
pub fn view_window<'a>(
w_buf: &'a str,
h_buf: &'a str,
plot_area: &'a str,
center: bool,
offset_x: &'a str,
offset_y: &'a str,
rotation: &'a str,
scale: &'a str,
plot_format: PaperSize,
plot_orientation: Orientation,
) -> Element<'a, Message> {
// ── Toolbar ───────────────────────────────────────────────────────────
let toolbar = container(
row![
button(text("Cancel").size(12))
.on_press(Message::PageSetupClose)
.style(btn(false))
.padding([4, 14]),
Space::new().width(Fill),
button(text("OK").size(12))
.on_press(Message::PageSetupCommit)
.style(btn(true))
.padding([4, 20]),
]
.align_y(iced::Center),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TB)),
..Default::default()
})
.width(Fill)
.padding([5, 10]);
let lbl = |s: &'static str| text(s).size(11).color(DIM).width(130);
// ── Paper size presets ────────────────────────────────────────────────
let presets1 = row![
button(text("A4 P").size(10))
.on_press(Message::PageSetupPreset("A4 Portrait".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A4 L").size(10))
.on_press(Message::PageSetupPreset("A4 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A3 P").size(10))
.on_press(Message::PageSetupPreset("A3 Portrait".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A3 L").size(10))
.on_press(Message::PageSetupPreset("A3 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
]
.spacing(4);
let presets2 = row![
button(text("A2 L").size(10))
.on_press(Message::PageSetupPreset("A2 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A1 L").size(10))
.on_press(Message::PageSetupPreset("A1 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A0 L").size(10))
.on_press(Message::PageSetupPreset("A0 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("Letter").size(10))
.on_press(Message::PageSetupPreset("Letter Landscape".into()))
.style(pill(false))
.padding([3, 6]),
]
.spacing(4);
// ── Rotation buttons ──────────────────────────────────────────────────
let rot_row = row![
button(text("0°").size(10))
.on_press(Message::PageSetupRotation("0".into()))
.style(pill(rotation == "0"))
.padding([3, 8]),
button(text("90°").size(10))
.on_press(Message::PageSetupRotation("90".into()))
.style(pill(rotation == "90"))
.padding([3, 8]),
button(text("180°").size(10))
.on_press(Message::PageSetupRotation("180".into()))
.style(pill(rotation == "180"))
.padding([3, 8]),
button(text("270°").size(10))
.on_press(Message::PageSetupRotation("270".into()))
.style(pill(rotation == "270"))
.padding([3, 8]),
]
.spacing(4);
// ── Scale buttons ─────────────────────────────────────────────────────
let scale_row1 = row![
button(text("Fit").size(10))
.on_press(Message::PageSetupScale("Fit".into()))
.style(pill(scale == "Fit"))
.padding([3, 8]),
button(text("1:1").size(10))
.on_press(Message::PageSetupScale("1:1".into()))
.style(pill(scale == "1:1"))
.padding([3, 8]),
button(text("1:2").size(10))
.on_press(Message::PageSetupScale("1:2".into()))
.style(pill(scale == "1:2"))
.padding([3, 8]),
button(text("1:5").size(10))
.on_press(Message::PageSetupScale("1:5".into()))
.style(pill(scale == "1:5"))
.padding([3, 8]),
button(text("1:10").size(10))
.on_press(Message::PageSetupScale("1:10".into()))
.style(pill(scale == "1:10"))
.padding([3, 8]),
]
.spacing(4);
let scale_row2 = row![
button(text("1:20").size(10))
.on_press(Message::PageSetupScale("1:20".into()))
.style(pill(scale == "1:20"))
.padding([3, 8]),
button(text("1:50").size(10))
.on_press(Message::PageSetupScale("1:50".into()))
.style(pill(scale == "1:50"))
.padding([3, 8]),
button(text("1:100").size(10))
.on_press(Message::PageSetupScale("1:100".into()))
.style(pill(scale == "1:100"))
.padding([3, 8]),
button(text("2:1").size(10))
.on_press(Message::PageSetupScale("2:1".into()))
.style(pill(scale == "2:1"))
.padding([3, 8]),
]
.spacing(4);
// ── Model-space window plot ───────────────────────────────────────────
// Sheet size/orientation for PLOTWINDOW's clipped export; the scale
// pills above (`scale_row1`/`scale_row2`) double as its plot scale.
let format_row = {
let mut r = row![lbl("Format")].spacing(4).align_y(iced::Center);
for size in PaperSize::ALL {
r = r.push(
button(text(size.label()).size(10))
.on_press(Message::PlotFormat(size))
.style(pill(plot_format == size))
.padding([3, 8]),
);
}
r
};
let orient_row = row![
lbl("Orientation"),
button(text("Portrait").size(10))
.on_press(Message::PlotOrientation(Orientation::Portrait))
.style(pill(plot_orientation == Orientation::Portrait))
.padding([3, 8]),
button(text("Landscape").size(10))
.on_press(Message::PlotOrientation(Orientation::Landscape))
.style(pill(plot_orientation == Orientation::Landscape))
.padding([3, 8]),
]
.spacing(4)
.align_y(iced::Center);
let window_row = row![
button(text("Pick window").size(10))
.on_press(Message::Command("PLOTWINDOW".into()))
.style(btn(false))
.padding([4, 10]),
button(text("Plot window → PDF").size(10))
.on_press(Message::PlotWindowExport)
.style(btn(true))
.padding([4, 10]),
]
.spacing(6);
// ── Main scrollable form ──────────────────────────────────────────────
let form = column![
section_label("Paper Size"),
presets1,
presets2,
row![
lbl("Width (mm)"),
text_input("297", w_buf)
.on_input(Message::PageSetupWidthEdit)
.on_submit(Message::PageSetupCommit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
row![
lbl("Height (mm)"),
text_input("210", h_buf)
.on_input(Message::PageSetupHeightEdit)
.on_submit(Message::PageSetupCommit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
hdivider(),
section_label("Plot Area"),
row![
button(text("Layout").size(10))
.on_press(Message::PageSetupPlotArea("Layout".into()))
.style(pill(plot_area == "Layout"))
.padding([3, 8]),
button(text("Extents").size(10))
.on_press(Message::PageSetupPlotArea("Extents".into()))
.style(pill(plot_area == "Extents"))
.padding([3, 8]),
]
.spacing(6),
hdivider(),
section_label("Position"),
button(
row![
crate::ui::icons::check_cell(center, Color::WHITE),
text("Center on page").size(11),
]
.spacing(2)
.align_y(iced::Center)
)
.on_press(Message::PageSetupCenterToggle)
.style(pill(center))
.padding([4, 10]),
row![
lbl("Offset X (mm)"),
text_input("0", offset_x)
.on_input(Message::PageSetupOffsetXEdit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
row![
lbl("Offset Y (mm)"),
text_input("0", offset_y)
.on_input(Message::PageSetupOffsetYEdit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
hdivider(),
section_label("Rotation"),
rot_row,
hdivider(),
section_label("Plot Scale"),
scale_row1,
scale_row2,
hdivider(),
section_label("Model-Space Window Plot"),
format_row,
orient_row,
window_row,
]
.spacing(10)
.padding(16)
.width(Fill);
let content = scrollable(form).width(Fill).height(Fill);
container(column![toolbar, hdivider(), content].spacing(0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}
+521
View File
@@ -0,0 +1,521 @@
//! Plot / Print dialog — a full plot setup surface rendered as an in-canvas
//! modal (Plan B). Bundles printer choice, paper, scale, offset, plot style,
//! quality and output options into one dialog; on commit it either sends the
//! current layout to a system printer (with the chosen options) or writes a
//! PDF. Styled to match the other OCS dialogs (dark pills + fields).
use crate::app::Message;
use crate::io::paper_sizes::PaperSize;
use iced::widget::{
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space,
};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
const TB: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 };
const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 };
const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 };
const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 };
const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 };
const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 };
const FIELD: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 };
/// Sentinel entries in the printer dropdown (not real printer names).
pub const OUT_DEFAULT: &str = "System default printer";
pub const OUT_PDF: &str = "Save to PDF file…";
/// One of the many boolean plot options (folded into a single message so the
/// dialog needn't carry a variant per checkbox).
#[derive(Debug, Clone, Copy)]
pub enum PlotFlag {
Center,
ScaleLw,
Mono,
Lineweights,
WithStyles,
Transparency,
PaperspaceLast,
HidePaperspace,
Stamp,
SaveLayout,
}
/// Every edit the Plot dialog can emit. Wrapped in `Message::PlotDlg` so the
/// top-level match stays a single arm.
#[derive(Debug, Clone)]
pub enum PlotDlgMsg {
Close,
Commit,
Preview,
Printer(String),
Paper(String),
Orientation(String),
Rotation(String),
Area(String),
Scale(String),
Quality(String),
Shade(String),
Copies(String),
OffsetX(String),
OffsetY(String),
Dpi(String),
Flag(PlotFlag),
LoadStyle,
ClearStyle,
PickWindow,
}
/// Transient state backing the Plot dialog. Seeded from the layout's plot
/// settings when the dialog opens; consumed on commit.
#[derive(Debug, Clone)]
pub struct PlotDialogState {
/// Printer names discovered on the system (via `lpstat`), never the
/// sentinels.
pub printers: Vec<String>,
/// Chosen printer name, or `None` for the system default.
pub printer: Option<String>,
/// Output goes to a PDF file instead of a printer.
pub to_file: bool,
pub paper: String,
pub orientation: String,
pub rotation: String,
pub copies: String,
pub area: String,
pub center: bool,
pub offset_x: String,
pub offset_y: String,
pub scale: String,
pub scale_lw: bool,
pub quality: String,
pub dpi: String,
pub shade: String,
pub mono: bool,
pub lineweights: bool,
pub with_styles: bool,
pub transparency: bool,
pub paperspace_last: bool,
pub hide_paperspace: bool,
pub stamp: bool,
pub save_layout: bool,
/// Display name of the active plot style table ("" = none).
pub style_name: String,
}
impl Default for PlotDialogState {
fn default() -> Self {
Self {
printers: Vec::new(),
printer: None,
to_file: false,
paper: "A4".into(),
orientation: "Landscape".into(),
rotation: "0°".into(),
copies: "1".into(),
area: "Window".into(),
center: true,
offset_x: "0.0".into(),
offset_y: "0.0".into(),
scale: "Fit".into(),
scale_lw: true,
quality: "Normal".into(),
dpi: "300".into(),
shade: "As displayed".into(),
mono: false,
lineweights: true,
with_styles: true,
transparency: false,
paperspace_last: false,
hide_paperspace: false,
stamp: false,
save_layout: false,
style_name: String::new(),
}
}
}
impl PlotDialogState {
/// Load the persisted print preferences (printer, copies, quality, output
/// options) from `<config>/OpenCADStudio/plot.txt`. Drawing-specific fields
/// (paper, scale, offset, rotation…) are NOT persisted here — they are
/// seeded from the active layout each time the dialog opens.
pub fn load() -> Self {
#[cfg(target_arch = "wasm32")]
{
Self::default()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut s = Self::default();
if let Some(body) = prefs_path().and_then(|p| std::fs::read_to_string(p).ok()) {
let flag = |v: &str| v == "1";
for line in body.lines() {
let Some((k, v)) = line.split_once('=') else {
continue;
};
let (k, v) = (k.trim(), v.trim());
match k {
"printer" => {
s.printer = if v.is_empty() { None } else { Some(v.to_string()) }
}
"to_file" => s.to_file = flag(v),
"area" => s.area = v.to_string(),
"scale" => s.scale = v.to_string(),
"copies" => s.copies = v.to_string(),
"quality" => s.quality = v.to_string(),
"dpi" => s.dpi = v.to_string(),
"shade" => s.shade = v.to_string(),
"mono" => s.mono = flag(v),
"lineweights" => s.lineweights = flag(v),
"with_styles" => s.with_styles = flag(v),
"transparency" => s.transparency = flag(v),
"paperspace_last" => s.paperspace_last = flag(v),
"hide_paperspace" => s.hide_paperspace = flag(v),
"stamp" => s.stamp = flag(v),
"save_layout" => s.save_layout = flag(v),
"scale_lw" => s.scale_lw = flag(v),
_ => {}
}
}
}
s
}
}
/// Best-effort persist of the print preferences (silent on failure).
#[cfg(not(target_arch = "wasm32"))]
pub fn save(&self) {
let Some(path) = prefs_path() else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let b = |v: bool| if v { "1" } else { "0" };
let printer = self.printer.clone().unwrap_or_default();
let body = format!(
"printer={}\nto_file={}\narea={}\nscale={}\ncopies={}\nquality={}\ndpi={}\nshade={}\nmono={}\n\
lineweights={}\nwith_styles={}\ntransparency={}\npaperspace_last={}\n\
hide_paperspace={}\nstamp={}\nsave_layout={}\nscale_lw={}\n",
printer,
b(self.to_file),
self.area,
self.scale,
self.copies,
self.quality,
self.dpi,
self.shade,
b(self.mono),
b(self.lineweights),
b(self.with_styles),
b(self.transparency),
b(self.paperspace_last),
b(self.hide_paperspace),
b(self.stamp),
b(self.save_layout),
b(self.scale_lw),
);
let _ = std::fs::write(path, body);
}
/// No-op persist on the web build (no filesystem).
#[cfg(target_arch = "wasm32")]
pub fn save(&self) {}
}
#[cfg(not(target_arch = "wasm32"))]
fn prefs_path() -> Option<std::path::PathBuf> {
Some(crate::config::config_dir()?.join("plot.txt"))
}
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (accent, st) {
(true, button::Status::Hovered | button::Status::Pressed) => {
Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 }
}
(false, button::Status::Hovered | button::Status::Pressed) => {
Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 }
}
(true, _) => ACCENT,
_ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 },
})),
text_color: TEXT,
border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
shadow: iced::Shadow::default(),
snap: false,
}
}
fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style {
text_input::Style {
background: Background::Color(FIELD),
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
icon: TEXT,
placeholder: DIM,
value: TEXT,
selection: ACCENT,
}
}
fn hdivider<'a>() -> Element<'a, Message> {
container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
})
.into()
}
fn section_label<'a>(s: &'static str) -> Element<'a, Message> {
text(s).size(11).color(DIM).into()
}
/// A `label : dropdown` row. `ctor` turns the picked string into a dialog
/// message.
fn drop_row<'a>(
label: &'a str,
options: Vec<String>,
selected: Option<String>,
ctor: fn(String) -> PlotDlgMsg,
) -> Element<'a, Message> {
let pl = pick_list(options, selected, move |s| Message::PlotDlg(ctor(s)))
.text_size(12)
.padding([3, 6])
.width(Length::Fill);
row![text(label).size(11).color(DIM).width(92), pl]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A `label : text field` row.
fn field_row<'a>(
label: &'a str,
value: &'a str,
ctor: fn(String) -> PlotDlgMsg,
width: u16,
) -> Element<'a, Message> {
row![
text(label).size(11).color(DIM).width(92),
text_input("", value)
.on_input(move |s| Message::PlotDlg(ctor(s)))
.style(field_style)
.size(12)
.width(width as f32),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
/// A single option checkbox bound to a `PlotFlag`.
fn check<'a>(label: &'a str, on: bool, flag: PlotFlag) -> Element<'a, Message> {
checkbox(on)
.label(label)
.on_toggle(move |_| Message::PlotDlg(PlotDlgMsg::Flag(flag)))
.size(14)
.text_size(11)
.into()
}
fn strs(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> {
// ── Toolbar: Cancel … Preview Print/Export ──────────────────────────
let action = if s.to_file { "Export PDF" } else { "Print" };
let toolbar = container(
row![
button(text("Cancel").size(12))
.on_press(Message::PlotDlg(PlotDlgMsg::Close))
.style(btn(false))
.padding([4, 14]),
Space::new().width(Fill),
button(text("Preview").size(12))
.on_press(Message::PlotDlg(PlotDlgMsg::Preview))
.style(btn(false))
.padding([4, 12]),
Space::new().width(8),
button(text(action).size(12))
.on_press(Message::PlotDlg(PlotDlgMsg::Commit))
.style(btn(true))
.padding([4, 20]),
]
.align_y(iced::Center),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TB)),
..Default::default()
})
.width(Fill)
.padding([5, 10]);
// ── Printer dropdown: default + discovered printers + PDF sentinel ────
let mut printer_opts = vec![OUT_DEFAULT.to_string()];
printer_opts.extend(s.printers.iter().cloned());
printer_opts.push(OUT_PDF.to_string());
let printer_sel = if s.to_file {
Some(OUT_PDF.to_string())
} else {
Some(s.printer.clone().unwrap_or_else(|| OUT_DEFAULT.to_string()))
};
let paper_opts: Vec<String> = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect();
// ── Left column ──────────────────────────────────────────────────────
let style_label: String = if s.style_name.is_empty() {
"(none)".into()
} else {
s.style_name.clone()
};
let left = column![
section_label("Printer / plotter"),
drop_row("Output", printer_opts, printer_sel, PlotDlgMsg::Printer),
field_row("Copies", &s.copies, PlotDlgMsg::Copies, 60),
hdivider(),
section_label("Paper"),
drop_row("Size", paper_opts, Some(s.paper.clone()), PlotDlgMsg::Paper),
drop_row(
"Orientation",
strs(&["Portrait", "Landscape"]),
Some(s.orientation.clone()),
PlotDlgMsg::Orientation,
),
drop_row(
"Rotation",
strs(&["0°", "90°", "180°", "270°"]),
Some(s.rotation.clone()),
PlotDlgMsg::Rotation,
),
hdivider(),
section_label("Plot area"),
row![
container(
pick_list(
strs(&["Layout", "Extents", "Display", "Window"]),
Some(s.area.clone()),
move |v| Message::PlotDlg(PlotDlgMsg::Area(v)),
)
.text_size(12)
.padding([3, 6])
.width(Length::Fill)
)
.width(Fill),
button(text("Pick…").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PickWindow))
.style(btn(false))
.padding([4, 10]),
]
.spacing(8)
.align_y(iced::Center),
section_label("Plot offset"),
column![
field_row("X (mm)", &s.offset_x, PlotDlgMsg::OffsetX, 70),
field_row("Y (mm)", &s.offset_y, PlotDlgMsg::OffsetY, 70),
]
.spacing(9),
check("Center the plot", s.center, PlotFlag::Center),
]
.spacing(9)
.width(Fill);
// ── Right column ─────────────────────────────────────────────────────
let right = column![
section_label("Plot scale"),
drop_row(
"Scale",
strs(&["Fit", "1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "2:1"]),
Some(s.scale.clone()),
PlotDlgMsg::Scale,
),
check("Scale lineweights", s.scale_lw, PlotFlag::ScaleLw),
hdivider(),
section_label("Plot style table (pen assignments)"),
row![
container(text(style_label).size(12).color(TEXT))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(FIELD)),
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
..Default::default()
})
.padding([4, 8])
.width(Fill),
button(text("Load…").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::LoadStyle))
.style(btn(false))
.padding([4, 10]),
button(text("Clear").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::ClearStyle))
.style(btn(false))
.padding([4, 10]),
]
.spacing(6)
.align_y(iced::Center),
hdivider(),
section_label("Quality"),
row![
container(
pick_list(
strs(&["Draft", "Normal", "High", "Maximum"]),
Some(s.quality.clone()),
move |v| Message::PlotDlg(PlotDlgMsg::Quality(v)),
)
.text_size(12)
.padding([3, 6])
.width(Length::Fill)
)
.width(Fill),
text("DPI").size(11).color(DIM),
text_input("", &s.dpi)
.on_input(move |v| Message::PlotDlg(PlotDlgMsg::Dpi(v)))
.style(field_style)
.size(12)
.width(56.0),
]
.spacing(8)
.align_y(iced::Center),
drop_row(
"Shaded plot",
strs(&["As displayed", "Wireframe", "Hidden", "Rendered"]),
Some(s.shade.clone()),
PlotDlgMsg::Shade,
),
hdivider(),
section_label("Plot options"),
row![
column![
check("Object lineweights", s.lineweights, PlotFlag::Lineweights),
check("Plot with styles", s.with_styles, PlotFlag::WithStyles),
check("Monochrome", s.mono, PlotFlag::Mono),
check("Plot transparency", s.transparency, PlotFlag::Transparency),
]
.spacing(6)
.width(Fill),
column![
check("Paperspace last", s.paperspace_last, PlotFlag::PaperspaceLast),
check("Hide paperspace", s.hide_paperspace, PlotFlag::HidePaperspace),
check("Plot stamp", s.stamp, PlotFlag::Stamp),
check("Save to layout", s.save_layout, PlotFlag::SaveLayout),
]
.spacing(6)
.width(Fill),
]
.spacing(10),
]
.spacing(9)
.width(Fill);
let body = row![left, right].spacing(18).padding(14).width(Fill);
let content = scrollable(body).width(Fill).height(Fill);
container(column![toolbar, hdivider(), content].spacing(0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}