diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c05a471d4..eddcfada7 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -58,6 +58,7 @@ type LayoutPlotParams = ( std::sync::Arc>, Vec, Vec, + crate::io::pdf_export::PlotGroupSplits, f64, f64, f64, @@ -71,6 +72,7 @@ type ClippedPlotParams = ( Vec, Vec, Vec, + crate::io::pdf_export::PlotGroupSplits, f64, f64, f64, @@ -120,22 +122,62 @@ fn plot_content_extents( fn plot_scene_content( scene: &crate::scene::Scene, + paper_space_last: bool, ) -> ( std::sync::Arc>, Vec, Vec, + crate::io::pdf_export::PlotGroupSplits, ) { - let mut wires = scene.entity_wires().as_ref().clone(); - let mut hatches = scene.paper_canvas_hatches().as_ref().clone(); - let mut wipeouts = scene.paper_canvas_wipeouts().as_ref().clone(); - if scene.current_layout != "Model" { - let (pattern_wires, viewport_hatches, viewport_wipeouts) = - scene.viewport_plot_fills(); - wires.extend(pattern_wires); - hatches.extend(viewport_hatches); - wipeouts.extend(viewport_wipeouts); + let (paper_wires, model_wires) = scene.plot_wire_groups(); + let paper_hatches = scene.paper_canvas_hatches().as_ref().clone(); + let paper_wipeouts = scene.paper_canvas_wipeouts().as_ref().clone(); + if scene.current_layout == "Model" { + let splits = crate::io::pdf_export::PlotGroupSplits { + wires: paper_wires.len(), + hatches: paper_hatches.len(), + wipeouts: paper_wipeouts.len(), + }; + return ( + std::sync::Arc::new(paper_wires), + paper_hatches, + paper_wipeouts, + splits, + ); } - (std::sync::Arc::new(wires), hatches, wipeouts) + let (model_pattern_wires, model_hatches, model_wipeouts) = + scene.viewport_plot_fills(); + + let (wires, hatches, wipeouts, splits) = if paper_space_last { + let splits = crate::io::pdf_export::PlotGroupSplits { + wires: model_wires.len() + model_pattern_wires.len(), + hatches: model_hatches.len(), + wipeouts: model_wipeouts.len(), + }; + let mut wires = model_wires; + let mut hatches = model_hatches; + let mut wipeouts = model_wipeouts; + wires.extend(model_pattern_wires); + wires.extend(paper_wires); + hatches.extend(paper_hatches); + wipeouts.extend(paper_wipeouts); + (wires, hatches, wipeouts, splits) + } else { + let splits = crate::io::pdf_export::PlotGroupSplits { + wires: paper_wires.len(), + hatches: paper_hatches.len(), + wipeouts: paper_wipeouts.len(), + }; + let mut wires = paper_wires; + let mut hatches = paper_hatches; + let mut wipeouts = paper_wipeouts; + wires.extend(model_wires); + wires.extend(model_pattern_wires); + hatches.extend(model_hatches); + wipeouts.extend(model_wipeouts); + (wires, hatches, wipeouts, splits) + }; + (std::sync::Arc::new(wires), hatches, wipeouts, splits) } impl OpenCADStudio { @@ -866,7 +908,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Follow the file's saved current UCS from the moment it opens. self.tabs[i].adopt_active_ucs_from_header(); // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP - // ($OSMODE) so each file keeps its state, like AutoCAD. + // ($OSMODE) so each file keeps its own state. self.adopt_header_sysvars(i); // Route shared CJK ideographs to the language matching this // drawing's code page (web per-language font split). Drop the @@ -1956,6 +1998,7 @@ pub(super) fn on_open_file(&mut self) -> Task { scale_str: &str, ) { let i = self.active_tab; + let dialog = self.plot_dialog.clone(); let layout_name = self.tabs[i].scene.current_layout.clone(); let plot_window = self.plot_window; if layout_name != "Model" { @@ -1991,6 +2034,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Find or create the PlotSettings object for this layout. use acadrust::objects::{ ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, + ShadePlotMode, ShadePlotResolutionLevel, }; let plot_handle = self.tabs[i] @@ -2028,6 +2072,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if let Some(ObjectType::PlotSettings(ps)) = ps_entry { ps.paper_width = w; ps.paper_height = h; + ps.paper_size = dialog.paper.clone(); ps.paper_units = PlotPaperUnits::Millimeters; ps.plot_type = match plot_area { "Window" => PlotType::Window, @@ -2040,9 +2085,9 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.set_plot_window(x0, y0, x1, y1); } } - ps.flags.plot_centered = center; - ps.origin_x = offset_x; - ps.origin_y = offset_y; + ps.flags.plot_centered = center && plot_area != "Layout"; + ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; + ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; ps.rotation = match rotation { 90 => PlotRotation::Degrees90, 180 => PlotRotation::Degrees180, @@ -2059,6 +2104,31 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.scale_numerator = num; ps.scale_denominator = den; } + ps.printer_name = if dialog.to_file { + crate::ui::window::plot::OUT_PDF.into() + } else { + dialog.printer.clone().unwrap_or_default() + }; + ps.current_style_sheet = dialog.style_name.clone(); + ps.flags.scale_lineweights = dialog.scale_lw; + ps.flags.print_lineweights = dialog.lineweights; + ps.flags.plot_plot_styles = dialog.with_styles; + ps.flags.draw_viewports_first = dialog.paperspace_last; + ps.flags.plot_hidden = false; + ps.shade_plot_mode = match dialog.shade.as_str() { + "Wireframe" => ShadePlotMode::Wireframe, + _ => ShadePlotMode::AsDisplayed, + }; + ps.shade_plot_resolution = match dialog.quality.as_str() { + "Draft" => ShadePlotResolutionLevel::Draft, + "Preview" => ShadePlotResolutionLevel::Preview, + "Presentation" => ShadePlotResolutionLevel::Presentation, + "Maximum" => ShadePlotResolutionLevel::Maximum, + "Custom" => ShadePlotResolutionLevel::Custom, + _ => ShadePlotResolutionLevel::Normal, + }; + ps.shade_plot_dpi = + dialog.dpi.parse::().unwrap_or(300).clamp(1, 32767); } self.tabs[i].dirty = true; @@ -2076,13 +2146,10 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, path: std::path::PathBuf, ) -> Task { - let (wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip) = + let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); - let plot_style = if self.plot_dialog.with_styles { - self.active_plot_style.clone() - } else { - None - }; + let plot_style = self.dialog_plot_style(&self.plot_dialog); + let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); background_task( move || { @@ -2099,6 +2166,7 @@ pub(super) fn on_open_file(&mut self) -> Task { clip, &worker_path, plot_style.as_ref(), + render_options, ) .map(|_| format!("Exported: {}", worker_path.display())) .map_err(|e| format!("Export failed: {e}")) @@ -2118,18 +2186,15 @@ pub(super) fn on_open_file(&mut self) -> Task { "Extents" => self.extents_plot_job(), _ => self.window_plot_job(), }; - let Some((wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip)) = + let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = job else { self.command_line .push_error("Plot area is empty. Pick a larger window."); return Task::none(); }; - let plot_style = if self.plot_dialog.with_styles { - self.active_plot_style.clone() - } else { - None - }; + let plot_style = self.dialog_plot_style(&self.plot_dialog); + let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); self.close_active_modal(); background_task( @@ -2147,6 +2212,7 @@ pub(super) fn on_open_file(&mut self) -> Task { clip, &worker_path, plot_style.as_ref(), + render_options, ) .map(|_| {format!( "Plotted window to {}", @@ -2167,10 +2233,15 @@ pub(super) fn on_open_file(&mut self) -> Task { let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; - let (source_wires, hatches, wipeouts) = plot_scene_content(scene); + let (source_wires, hatches, wipeouts, mut group_splits) = + plot_scene_content(scene, self.plot_dialog.paperspace_last); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. let wires = if paper_space { + group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())] + .iter() + .filter(|wire| wire.name != "paper_printable_area") + .count(); std::sync::Arc::new( source_wires .iter() @@ -2190,22 +2261,15 @@ pub(super) fn on_open_file(&mut self) -> Task { let mm_per_unit = 1.0 / units_per_mm; let paper_w = ((x1 - x0) * mm_per_unit).max(1.0); let paper_h = ((y1 - y0) * mm_per_unit).max(1.0); - // Preview/print is a read-only operation unless "Save to layout" is - // checked. Therefore its transient choices must come from the dialog, - // not the layout's previously persisted PlotSettings. - let dialog_rotation = self - .plot_dialog - .rotation - .trim_end_matches('°') - .parse::() - .unwrap_or(0); + // Positioning choices are transient until explicitly saved. Layout + // area keeps the sheet's current physical bounds; the other area + // modes use the dialog paper size through `area_plot_job`. + let dialog_rotation = if self.plot_dialog.upside_down { 180 } else { 0 }; let plot_extents = self.plot_dialog.area == "Extents"; let plot_layout = self.plot_dialog.area == "Layout"; - // `paper_limits()` already returns the current layout's final, - // rotation-adjusted sheet bounds. Applying its persisted plot - // rotation again swaps the page and rotates the printable clip out - // of view, producing a blank PDF for rotated custom sheets. - let rotation = if plot_layout { 0 } else { dialog_rotation }; + // Page orientation is already represented by the sheet bounds. + // Only the explicit upside-down choice rotates layout content here. + let rotation = dialog_rotation; let centered = self.plot_dialog.center; let origin_x = self .plot_dialog @@ -2293,6 +2357,7 @@ pub(super) fn on_open_file(&mut self) -> Task { wires, hatches, wipeouts, + group_splits, page_w, page_h, offset_x, @@ -2324,6 +2389,7 @@ pub(super) fn on_open_file(&mut self) -> Task { wires, hatches, wipeouts, + group_splits, page_w, page_h, offset_x, @@ -2335,20 +2401,17 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_print_to_printer(&mut self) -> Task { - let (wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip) = + let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); - let plot_style = if self.plot_dialog.with_styles { - self.active_plot_style.clone() - } else { - None - }; + let plot_style = self.dialog_plot_style(&self.plot_dialog); + let options = self.plot_print_options(&self.plot_dialog, group_splits); self.command_line.push_info("Sending to system printer…"); background_task( move || { iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires( + crate::io::print_to_printer::print_wires_with( wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, + plot_style, options, )) }, Message::PrintResult, @@ -2448,6 +2511,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // active layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); + d.paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; d.paper = self.plot_format.label().to_string(); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", @@ -2459,6 +2523,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_ref() .map(|t| t.name.clone()) .unwrap_or_default(); + d.style_missing = false; + d.with_styles &= !d.style_name.is_empty(); // `area` and `scale` are remembered user choices (default Window / Fit), // not reseeded from the layout. Offset / center / rotation ARE layout // properties, so reflect them. @@ -2468,7 +2534,7 @@ pub(super) fn on_open_file(&mut self) -> Task { 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}°"); + d.upside_down = matches!(deg, 180 | 270); } self.plot_dialog.name_input = None; self.plot_dialog.name_rename = false; @@ -2476,10 +2542,15 @@ pub(super) fn on_open_file(&mut self) -> Task { // snapshot the just-loaded settings as the `` restore point. self.refresh_page_setups(); self.plot_prev = Some(self.plot_dialog.clone()); - // Default selection: model space → ; a layout → its own setup. + // Model space keeps the last-used setup; a paper layout starts from its + // embedded setup. let cur = self.tabs[self.active_tab].scene.current_layout.clone(); if cur == "Model" { - self.select_page_setup(crate::ui::window::plot::SETUP_NONE); + self.select_page_setup(crate::ui::window::plot::SETUP_PREV); + if self.plot_dialog.area == "Layout" { + self.plot_dialog.area = "Window".into(); + self.normalize_common_plot_dialog(); + } } else { self.select_page_setup(&format!("*{cur}*")); if self.plot_dialog.area != "Layout" { @@ -2495,24 +2566,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn normalize_common_plot_dialog(&mut self) { - let layout_rotation = self - .plot_dialog - .rotation - .trim_end_matches('°') - .parse::() - .unwrap_or(0); - if matches!(layout_rotation, 90 | 270) { - self.plot_dialog.orientation = if self.plot_dialog.orientation == "Portrait" { - "Landscape" - } else { - "Portrait" - } - .into(); - } self.plot_dialog.center = true; self.plot_dialog.offset_x = "0.0".into(); self.plot_dialog.offset_y = "0.0".into(); - self.plot_dialog.rotation = "0°".into(); self.plot_dialog.scale = "Fit".into(); } @@ -2539,6 +2595,15 @@ pub(super) fn on_open_file(&mut self) -> Task { } Task::none() } + M::PrinterProperties => { + match crate::io::print_to_printer::open_printer_properties( + self.plot_dialog.printer.as_deref(), + ) { + Ok(()) => self.command_line.push_info("Opened printer properties."), + Err(error) => self.command_line.push_error(&error), + } + Task::none() + } M::Paper(s) => { self.plot_dialog.paper = s; Task::none() @@ -2547,28 +2612,40 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.orientation = s; Task::none() } - M::Rotation(s) => { - self.plot_dialog.rotation = s; - Task::none() - } M::Area(s) => { // A paper Layout page setup commonly carries physical-sheet - // rotation, origin and 1:1 scale. Those values are correct only - // for Layout; carrying them into Window/Extents/Display moves a - // large selected area off-page. Enter the three common area - // modes with the same neutral defaults used in Model space. + // origin and 1:1 scale. Those values are correct only for + // Layout; carrying them into Window/Extents/Display moves a + // large selected area off-page. Enter the common area modes + // with centered, fit-to-paper defaults. if self.plot_dialog.area == "Layout" && s != "Layout" { self.normalize_common_plot_dialog(); } + if s == "Layout" { + self.plot_dialog.center = false; + self.plot_dialog.offset_x = "0.0".into(); + self.plot_dialog.offset_y = "0.0".into(); + self.plot_dialog.scale = "1:1".into(); + } self.plot_dialog.area = s; Task::none() } M::Scale(s) => { - self.plot_dialog.scale = s; + if self.plot_dialog.area != "Layout" { + self.plot_dialog.scale = s; + } Task::none() } M::Quality(s) => { - self.plot_dialog.quality = s; + self.plot_dialog.quality = s.clone(); + self.plot_dialog.dpi = match s.as_str() { + "Draft" | "Preview" => "150", + "Presentation" => "600", + "Maximum" => "", + "Custom" => return Task::none(), + _ => "300", + } + .into(); Task::none() } M::Shade(s) => { @@ -2594,16 +2671,23 @@ pub(super) fn on_open_file(&mut self) -> Task { 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::Center if d.area != "Layout" => d.center = !d.center, + PlotFlag::ScaleLw if d.area != "Layout" => d.scale_lw = !d.scale_lw, + PlotFlag::UpsideDown => { + d.upside_down = !d.upside_down; + } PlotFlag::Mono => d.mono = !d.mono, PlotFlag::Lineweights => d.lineweights = !d.lineweights, - PlotFlag::WithStyles => d.with_styles = !d.with_styles, + PlotFlag::WithStyles if !d.style_name.is_empty() => { + 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::PaperspaceLast if d.paper_space => { + d.paperspace_last = !d.paperspace_last + } PlotFlag::Stamp => d.stamp = !d.stamp, - PlotFlag::SaveLayout => d.save_layout = !d.save_layout, + PlotFlag::SaveLayout if d.paper_space => d.save_layout = !d.save_layout, + _ => {} } Task::none() } @@ -2611,6 +2695,8 @@ pub(super) fn on_open_file(&mut self) -> Task { M::ClearStyle => { self.active_plot_style = None; self.plot_dialog.style_name.clear(); + self.plot_dialog.style_missing = false; + self.plot_dialog.with_styles = false; Task::none() } M::PickWindow => { @@ -2759,7 +2845,7 @@ pub(super) fn on_open_file(&mut self) -> Task { d.center = true; d.offset_x = "0.0".into(); d.offset_y = "0.0".into(); - d.rotation = "0°".into(); + d.upside_down = false; d.scale = "Fit".into(); } else if name == SETUP_PREV { if let Some(prev) = self.plot_prev.clone() { @@ -2795,7 +2881,8 @@ pub(super) fn on_open_file(&mut self) -> Task { /// page setup). fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings { use acadrust::objects::{ - PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, + PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, + ShadePlotResolutionLevel, }; use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let d = &self.plot_dialog; @@ -2815,6 +2902,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let mut ps = PlotSettings::new(""); ps.paper_width = w; ps.paper_height = h; + ps.paper_size = d.paper.clone(); ps.paper_units = PlotPaperUnits::Millimeters; ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, @@ -2827,17 +2915,27 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.set_plot_window(x0, y0, x1, y1); } } - ps.flags.plot_centered = d.center; - ps.origin_x = d.offset_x.parse::().unwrap_or(0.0); - ps.origin_y = d.offset_y.parse::().unwrap_or(0.0); - let rot: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); + ps.flags.plot_centered = d.center && d.area != "Layout"; + ps.origin_x = if d.area == "Layout" { + 0.0 + } else { + d.offset_x.parse::().unwrap_or(0.0) + }; + ps.origin_y = if d.area == "Layout" { + 0.0 + } else { + d.offset_y.parse::().unwrap_or(0.0) + }; + let rot: i16 = if d.upside_down { 180 } else { 0 }; ps.rotation = match rot { 90 => PlotRotation::Degrees90, 180 => PlotRotation::Degrees180, 270 => PlotRotation::Degrees270, _ => PlotRotation::None, }; - if d.scale == "Fit" { + if d.area == "Layout" { + ps.set_standard_scale(ScaledType::OneToOne); + } else if d.scale == "Fit" { ps.set_scale_to_fit(); } else { let (num, den) = parse_plot_scale(&d.scale); @@ -2845,14 +2943,38 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.scale_numerator = num; ps.scale_denominator = den; } - ps.printer_name = d.printer.clone().unwrap_or_default(); + ps.printer_name = if d.to_file { + crate::ui::window::plot::OUT_PDF.into() + } else { + d.printer.clone().unwrap_or_default() + }; ps.current_style_sheet = d.style_name.clone(); + ps.flags.scale_lineweights = d.scale_lw; + ps.flags.print_lineweights = d.lineweights; + ps.flags.plot_plot_styles = d.with_styles; + ps.flags.draw_viewports_first = d.paperspace_last; + ps.flags.plot_hidden = false; + ps.shade_plot_mode = match d.shade.as_str() { + "Wireframe" => ShadePlotMode::Wireframe, + _ => ShadePlotMode::AsDisplayed, + }; + ps.shade_plot_resolution = match d.quality.as_str() { + "Draft" => ShadePlotResolutionLevel::Draft, + "Preview" => ShadePlotResolutionLevel::Preview, + "Presentation" => ShadePlotResolutionLevel::Presentation, + "Maximum" => ShadePlotResolutionLevel::Maximum, + "Custom" => ShadePlotResolutionLevel::Custom, + _ => ShadePlotResolutionLevel::Normal, + }; + ps.shade_plot_dpi = d.dpi.parse::().unwrap_or(300).clamp(1, 32767); ps } /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { - use acadrust::objects::PlotType; + use acadrust::objects::{ + PlotType, ShadePlotMode, ShadePlotResolutionLevel, + }; if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() { self.plot_window = Some(( ps.plot_window.lower_left_x, @@ -2861,6 +2983,11 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.plot_window.upper_right_y, )); } + let style_loaded = !ps.current_style_sheet.is_empty() + && self + .active_plot_style + .as_ref() + .is_some_and(|table| table.name == ps.current_style_sheet); let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); let d = &mut self.plot_dialog; d.paper = paper; @@ -2872,12 +2999,25 @@ pub(super) fn on_open_file(&mut self) -> Task { _ => "Extents", } .to_string(); + if !d.paper_space && d.area == "Layout" { + d.area = "Extents".into(); + } 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}°"); - d.scale = if ps.is_scale_to_fit() { + if matches!(deg, 90 | 270) { + d.orientation = if d.orientation == "Portrait" { + "Landscape" + } else { + "Portrait" + } + .into(); + } + d.upside_down = matches!(deg, 180 | 270); + d.scale = if d.area == "Layout" { + "1:1".into() + } else if ps.is_scale_to_fit() { "Fit".into() } else { let n = ps.scale_numerator; @@ -2890,6 +3030,38 @@ pub(super) fn on_open_file(&mut self) -> Task { "Fit".into() } }; + d.scale_lw = ps.flags.scale_lineweights; + d.lineweights = ps.flags.print_lineweights; + d.with_styles = ps.flags.plot_plot_styles; + d.paperspace_last = ps.flags.draw_viewports_first; + d.shade = match ps.shade_plot_mode { + ShadePlotMode::Wireframe => "Wireframe", + _ => "As displayed", + } + .into(); + d.quality = match ps.shade_plot_resolution { + ShadePlotResolutionLevel::Draft => "Draft", + ShadePlotResolutionLevel::Preview => "Preview", + ShadePlotResolutionLevel::Presentation => "Presentation", + ShadePlotResolutionLevel::Maximum => "Maximum", + ShadePlotResolutionLevel::Custom => "Custom", + _ => "Normal", + } + .into(); + d.dpi = if d.quality == "Maximum" { + String::new() + } else { + ps.shade_plot_dpi.max(1).to_string() + }; + if ps.printer_name.to_ascii_lowercase().contains("pdf") { + d.to_file = true; + d.printer = None; + } else { + d.to_file = false; + d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); + } + d.style_name = ps.current_style_sheet.clone(); + d.style_missing = !d.style_name.is_empty() && !style_loaded; } /// Copy transient paper/scale choices out of the dialog without changing @@ -2911,7 +3083,11 @@ pub(super) fn on_open_file(&mut self) -> Task { }; self.plot_format = paper; self.plot_orientation = orient; - self.plot_scale = d.scale.clone(); + self.plot_scale = if d.area == "Layout" { + "1:1".into() + } else { + d.scale.clone() + }; } fn apply_dialog_to_layout(&mut self) { @@ -2919,7 +3095,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.sync_dialog_plot_runtime(); let d = self.plot_dialog.clone(); let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); - let rotation: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); + let rotation: i16 = if d.upside_down { 180 } else { 0 }; let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); self.apply_plot_page_settings( @@ -2930,16 +3106,23 @@ pub(super) fn on_open_file(&mut self) -> Task { off_x, off_y, rotation, - &d.scale, + if d.area == "Layout" { "1:1" } else { &d.scale }, ); } /// Open a preview PDF, export a PDF, or send the job to the chosen printer. /// The active layout changes only when "Save to layout" is checked. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { + let d = self.plot_dialog.clone(); + if d.with_styles && d.style_missing { + self.command_line.push_error(&format!( + "Plot style table '{}' is not loaded.", + d.style_name + )); + return Task::none(); + } // Remember the user's print preferences across sessions. self.save_config(); - let d = self.plot_dialog.clone(); // Preview and normal printing must not resize/mutate the live layout. // Persist only when explicitly requested; the runtime paper/scale // choices still drive this one plot operation. @@ -2949,12 +3132,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } self.active_modal = None; - let plot_style = if d.with_styles { - self.active_plot_style.clone() - } else { - None - }; - + let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. if matches!(d.area.as_str(), "Extents" | "Window" | "Display") { @@ -2967,6 +3145,7 @@ pub(super) fn on_open_file(&mut self) -> Task { w_wires, w_hatches, w_wipeouts, + wgroup_splits, sw, sh, wox, @@ -2985,11 +3164,13 @@ pub(super) fn on_open_file(&mut self) -> Task { // whatever the output target is. if preview { let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let render_options = Self::pdf_plot_options(&d, wgroup_splits); return background_task( move || { crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), + render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) }, @@ -3001,12 +3182,14 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotWindowExport); } let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); - let opts = self.plot_print_options(&d); + let render_options = Self::pdf_plot_options(&d, wgroup_splits); + let opts = self.plot_print_options(&d, wgroup_splits); return background_task( move || { crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), + render_options, ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) .map(|printer| format!("Sent to printer: {printer}")) .map_err(|e| format!("Print failed: {e}")) @@ -3015,11 +3198,12 @@ pub(super) fn on_open_file(&mut self) -> Task { ); } - let (wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip) = + let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); if preview { let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let render_options = Self::pdf_plot_options(&d, group_splits); return background_task( move || { crate::io::pdf_export::export_pdf( @@ -3035,6 +3219,7 @@ pub(super) fn on_open_file(&mut self) -> Task { clip, &tmp, plot_style.as_ref(), + render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) }, @@ -3047,7 +3232,7 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotExport); } - let opts = self.plot_print_options(&d); + let opts = self.plot_print_options(&d, group_splits); self.command_line.push_info("Sending to system printer…"); background_task( move || { @@ -3066,16 +3251,54 @@ pub(super) fn on_open_file(&mut self) -> Task { fn plot_print_options( &self, d: &crate::ui::window::plot::PlotDialogState, + group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::print_to_printer::PrintOptions { + let dpi = match d.quality.as_str() { + "Draft" | "Preview" => Some(150), + "Normal" => Some(300), + "Presentation" => Some(600), + "Maximum" => None, + "Custom" => d.dpi.trim().parse::().ok().filter(|value| *value > 0), + _ => Some(300), + }; crate::io::print_to_printer::PrintOptions { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), mono: d.mono, quality: Some(d.quality.clone()), - dpi: d.dpi.trim().parse::().ok().filter(|v| *v > 0), + dpi, + render: Self::pdf_plot_options(d, group_splits), } } + fn pdf_plot_options( + d: &crate::ui::window::plot::PlotDialogState, + group_splits: crate::io::pdf_export::PlotGroupSplits, + ) -> crate::io::pdf_export::PdfPlotOptions { + crate::io::pdf_export::PdfPlotOptions { + object_lineweights: d.lineweights, + scale_lineweights: d.scale_lw, + transparency: d.transparency, + monochrome: d.mono, + wireframe: d.shade == "Wireframe", + stamp: d.stamp, + group_splits, + } + } + + fn dialog_plot_style( + &self, + d: &crate::ui::window::plot::PlotDialogState, + ) -> Option { + if !d.with_styles || d.style_missing { + return None; + } + self.active_plot_style + .as_ref() + .filter(|table| table.name == d.style_name) + .cloned() + } + fn window_plot_job(&self) -> Option { self.area_plot_job(self.plot_window?) } @@ -3095,7 +3318,8 @@ pub(super) fn on_open_file(&mut self) -> Task { max.y as f64, )); } - let (wires, hatches, wipeouts) = plot_scene_content(scene); + let (wires, hatches, wipeouts, _) = + plot_scene_content(scene, self.plot_dialog.paperspace_last); let extents = plot_content_extents( &wires .iter() @@ -3205,13 +3429,20 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); - let (all_wires, hatches, wipeouts) = plot_scene_content(scene); + let (all_wires, hatches, wipeouts, mut group_splits) = + plot_scene_content(scene, self.plot_dialog.paperspace_last); + let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] + .iter() + .filter(|w| w.name != "paper_printable_area") + .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) + .count(); let wires: Vec<_> = all_wires .iter() .filter(|w| w.name != "paper_printable_area") .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) .cloned() .collect(); + group_splits.wires = first_wire_count; let offset_x = (target_x / scale) - x0; let offset_y = (target_y / scale) - y0; let clip = Some(( @@ -3220,12 +3451,7 @@ pub(super) fn on_open_file(&mut self) -> Task { win_w as f32, win_h as f32, )); - let rotation = self - .plot_dialog - .rotation - .trim_end_matches('°') - .parse::() - .unwrap_or(0); + let rotation = if self.plot_dialog.upside_down { 180 } else { 0 }; let (page_w, page_h) = match rotation { 90 | 270 => (sheet_h, sheet_w), _ => (sheet_w, sheet_h), @@ -3234,6 +3460,7 @@ pub(super) fn on_open_file(&mut self) -> Task { wires, hatches, wipeouts, + group_splits, page_w, page_h, offset_x, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d26a516c9..db95ebbc7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -5168,6 +5168,15 @@ impl OpenCADStudio { Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded) } Message::PlotStyleLoaded(Some(table)) => { + if table.is_stb { + self.command_line.push_error( + "Named plot style tables are not supported by the vector plotter.", + ); + return Task::none(); + } + self.plot_dialog.style_name = table.name.clone(); + self.plot_dialog.style_missing = false; + self.plot_dialog.with_styles = true; self.command_line.push_output(&format!( "Plot style '{}' loaded ({} color entries).", table.name, @@ -5183,6 +5192,9 @@ impl OpenCADStudio { Message::PlotStyleLoaded(None) => Task::none(), Message::PlotStyleClear => { self.active_plot_style = None; + self.plot_dialog.style_name.clear(); + self.plot_dialog.style_missing = false; + self.plot_dialog.with_styles = false; self.command_line.push_output("Plot style table cleared."); Task::none() } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 9e769486e..e8f8aa1b9 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -240,8 +240,8 @@ impl OpenCADStudio { super::super::ModalKind::Plot => { sized_flow( ex, - 760, - 540, + 820, + 610, |flow| crate::ui::window::plot::view_window(&self.plot_dialog, flow), ) } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 7faf5019f..1c8ad36f0 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -15,8 +15,9 @@ use crate::scene::model::hatch_model::HatchPattern; use crate::scene::WireModel; #[cfg(not(target_arch = "wasm32"))] use printpdf::{ - Color, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, - PdfDocument, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, WindingOrder, + BuiltinFont, Color, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, + PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, + Pt, Rgb, TextItem, WindingOrder, }; #[cfg(not(target_arch = "wasm32"))] use std::io::Write; @@ -39,6 +40,7 @@ pub fn export_pdf( _clip: Option<(f32, f32, f32, f32)>, _path: &Path, _plot_style: Option<&PlotStyleTable>, + _options: PdfPlotOptions, ) -> Result<(), String> { Err("PDF export is not available in the web version.".into()) } @@ -59,6 +61,40 @@ const MM_TO_PT: f32 = 2.834645; #[cfg(not(target_arch = "wasm32"))] const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0); +/// Output controls shared by preview, PDF export, and printer rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdfPlotOptions { + pub object_lineweights: bool, + pub scale_lineweights: bool, + pub transparency: bool, + pub monochrome: bool, + pub wireframe: bool, + pub stamp: bool, + pub group_splits: PlotGroupSplits, +} + +/// End indexes of the first paper/model render group in each flat input list. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PlotGroupSplits { + pub wires: usize, + pub hatches: usize, + pub wipeouts: usize, +} + +impl Default for PdfPlotOptions { + fn default() -> Self { + Self { + object_lineweights: true, + scale_lineweights: false, + transparency: false, + monochrome: false, + wireframe: false, + stamp: false, + group_splits: PlotGroupSplits::default(), + } + } +} + // ── Public entry point ──────────────────────────────────────────────────── /// Export `wires` to a PDF file. @@ -81,6 +117,7 @@ pub fn export_pdf( clip: Option<(f32, f32, f32, f32)>, path: &Path, plot_style: Option<&PlotStyleTable>, + options: PdfPlotOptions, ) -> Result<(), String> { let bytes = build_pdf( wires, @@ -94,6 +131,7 @@ pub fn export_pdf( scale, clip, plot_style, + options, ); let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; file.write_all(&bytes).map_err(|e| e.to_string()) @@ -139,6 +177,7 @@ fn build_pdf( scale: f32, clip: Option<(f32, f32, f32, f32)>, plot_style: Option<&PlotStyleTable>, + options: PdfPlotOptions, ) -> Vec { let mut doc = PdfDocument::new("Open CAD Studio Export"); let mut ops: Vec = Vec::new(); @@ -230,11 +269,21 @@ fn build_pdf( } - // ── Hatch / wipeout fills (rendered before wires so wires draw on top, - // matching paper_canvas ordering). Each `emit_hatch` sets its own - // fill / stroke colour, so the wire pass below starts fresh. + let (first_wires, second_wires) = + wires.split_at(options.group_splits.wires.min(wires.len())); + let (first_hatches, second_hatches) = + hatches.split_at(options.group_splits.hatches.min(hatches.len())); + let (first_wipeouts, second_wipeouts) = + wipeouts.split_at(options.group_splits.wipeouts.min(wipeouts.len())); + for (wires, hatches, wipeouts) in [ + (first_wires, first_hatches, first_wipeouts), + (second_wires, second_hatches, second_wipeouts), + ] { + emit_wire_fills(&mut ops, wires, ox, oy, plot_style, options); + + // Hatch / wipeout fills render before wires so linework stays visible. for hatch in wipeouts.iter().chain(hatches.iter()) { - emit_hatch(&mut ops, hatch, ox, oy); + emit_hatch(&mut ops, hatch, ox, oy, plot_style, options); } let mut last_color: Option<[f32; 3]> = None; @@ -255,21 +304,25 @@ fn build_pdf( } // Apply CTB plot style table overrides (color + lineweight). let mut lw_override: Option = None; + let mut screening = 1.0; + let mut color_overridden = false; if let Some(ctb) = plot_style { if wire.aci > 0 { if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) { r = cr; g = cg; b = cb; + color_overridden = true; } lw_override = ctb .resolve_lineweight(wire.aci) - .map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6)); + .map(|mm| (mm * MM_TO_PT).max(0.1)); + screening = ctb.resolve_screening(wire.aci); } } // Near-white and near-yellow (viewport active border) → dark grey for print // (only when no CTB override was applied). - if lw_override.is_none() { + if !color_overridden { let is_light = r > 0.80 && g > 0.80 && b > 0.80; let is_yellow = r > 0.80 && g > 0.70 && b < 0.30; let is_cyan = r < 0.30 && g > 0.70 && b > 0.70; @@ -284,6 +337,7 @@ fn build_pdf( b = 0.50; } } + [r, g, b] = plotted_color([r, g, b], a, screening, options, false); if last_color .map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01) @@ -300,10 +354,9 @@ fn build_pdf( last_color = Some([r, g, b]); } - // Line weight: CTB override (in pt) or screen px → points. Divided by - // `scale` in both branches so pen widths stay absolute under the scaled - // CTM above — lineweights are independent of plot scale, so without this - // a Fit plot of a large window renders near-invisible hairlines. + // Line weight: style override or object weight. Normal output divides + // by the page transform so physical pen widths stay constant; the + // scale-lineweights option deliberately keeps the transformed width. // // A wide polyline is the exception: its band is a geometric width in // drawing units, so it must SCALE with the plot (no `/ scale`). Stroke @@ -313,11 +366,22 @@ fn build_pdf( // replaces the model-space hatch band that the shader-band change // dropped, and overrides any CTB pen weight (the width is geometry, not // a lineweight). + let pen_divisor = if options.scale_lineweights { + 1.0 + } else { + scale.max(1e-6) + }; let lw_pt = if wire.world_width > 0.0 { wire.world_width * MM_TO_PT } else { - lw_override - .unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1) / scale.max(1e-6)) + let physical = lw_override.unwrap_or_else(|| { + if options.object_lineweights { + (wire.line_weight_px * LW_PX_TO_PT).max(0.1) + } else { + 0.1 + } + }); + physical / pen_divisor }; if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) { ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) }); @@ -367,11 +431,15 @@ fn build_pdf( // this CPU exporter can't sample, so without this pass all text — including // dimension text — is missing from the PDF (issue #385). Drawn after the // wires (on top) and under the same rotation/scale/clip CTM. - emit_text(&mut ops, wires, ox, oy, scale, plot_style); + emit_text(&mut ops, wires, ox, oy, scale, plot_style, options); + } if needs_state { ops.push(Op::RestoreGraphicsState); } + if options.stamp { + emit_plot_stamp(&mut ops); + } let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops); doc.pages.push(page); @@ -418,13 +486,144 @@ fn flush_line(ops: &mut Vec, pts: &[LinePoint]) { }); } +#[cfg(not(target_arch = "wasm32"))] +fn plotted_color( + mut rgb: [f32; 3], + alpha: f32, + screening: f32, + options: PdfPlotOptions, + preserve_white: bool, +) -> [f32; 3] { + if options.monochrome && !preserve_white { + rgb = [0.0, 0.0, 0.0]; + } + let amount = screening.clamp(0.0, 1.0) + * if options.transparency { + alpha.clamp(0.0, 1.0) + } else { + 1.0 + }; + [ + 1.0 - (1.0 - rgb[0]) * amount, + 1.0 - (1.0 - rgb[1]) * amount, + 1.0 - (1.0 - rgb[2]) * amount, + ] +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit_wire_fills( + ops: &mut Vec, + wires: &[WireModel], + ox: f64, + oy: f64, + plot_style: Option<&PlotStyleTable>, + options: PdfPlotOptions, +) { + for wire in wires { + if wire.fill_tris.is_empty() || (options.wireframe && wire.fill_is_3d) { + continue; + } + let [mut r, mut g, mut b, a] = wire.color; + if a < 0.01 { + continue; + } + let mut screening = 1.0; + let mut color_overridden = false; + if let Some(table) = plot_style { + if wire.aci > 0 { + if let Some(color) = table.resolve_color(wire.aci) { + [r, g, b] = color; + color_overridden = true; + } + screening = table.resolve_screening(wire.aci); + } + } + if !color_overridden { + [r, g, b] = adapt_text_color([r, g, b]); + } + [r, g, b] = plotted_color([r, g, b], a, screening, options, false); + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }), + }); + for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() { + let mut points = Vec::with_capacity(3); + for (point_index, &[x, y, _]) in triangle.iter().enumerate() { + let index = triangle_index * 3 + point_index; + let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]); + points.push(LinePoint { + p: Point::new( + Mm((x as f64 + low[0] as f64 + ox) as f32), + Mm((y as f64 + low[1] as f64 + oy) as f32), + ), + bezier: false, + }); + } + ops.push(Op::DrawPolygon { + polygon: Polygon { + rings: vec![PolygonRing { points }], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }, + }); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit_plot_stamp(ops: &mut Vec) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let user = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "user".into()); + let label = format!("Open CAD Studio | {user} | {timestamp}"); + ops.extend([ + Op::SaveGraphicsState, + Op::StartTextSection, + Op::SetTextCursor { + pos: Point::new(Mm(4.0), Mm(3.0)), + }, + Op::SetFont { + font: PdfFontHandle::Builtin(BuiltinFont::Helvetica), + size: Pt(6.0), + }, + Op::SetFillColor { + col: Color::Rgb(Rgb { + r: 0.25, + g: 0.25, + b: 0.25, + icc_profile: None, + }), + }, + Op::ShowText { + items: vec![TextItem::Text(label)], + }, + Op::EndTextSection, + Op::RestoreGraphicsState, + ]); +} + /// Emit a single hatch / wipeout as a filled (or stroked, for pattern fills) /// polygon. NaN sentinels in `hatch.boundary` split the path into multiple /// rings so islands and holes render correctly under the even-odd rule. /// Mirrors `scene::paper_canvas::draw_hatch`: solid → fill, pattern → outline, /// gradient → solid fill of the averaged colour. #[cfg(not(target_arch = "wasm32"))] -fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f64, oy: f64) { +fn emit_hatch( + ops: &mut Vec, + hatch: &HatchModel, + ox: f64, + oy: f64, + plot_style: Option<&PlotStyleTable>, + options: PdfPlotOptions, +) { if hatch.boundary.is_empty() { return; } @@ -435,15 +634,31 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f64, oy: f64) { // Adapt hatch fills to the white sheet, mirroring the wire pass: colours // arrive adapted to the (dark) screen background, so a white/ACI-7 fill // would vanish white-on-white on paper. Force near-white/near-yellow → black - // and near-cyan → dark blue, matching AutoCAD's colour-7-on-white plotting. + // and near-cyan → dark blue for a readable white-sheet result. // Genuine colours are untouched; WIPEOUTS keep their paper-white mask. - if hatch.name == "WIPEOUT_FILL" { + let is_wipeout = hatch.name == "WIPEOUT_FILL"; + let mut screening = 1.0; + let mut color_overridden = false; + if !is_wipeout { + if let Some(table) = plot_style { + if hatch.aci > 0 { + if let Some([cr, cg, cb]) = table.resolve_color(hatch.aci) { + r = cr; + g = cg; + b = cb; + color_overridden = true; + } + screening = table.resolve_screening(hatch.aci); + } + } + } + if is_wipeout { // Screen wipeouts match the configured canvas colour; printed // wipeouts must mask with the white paper colour. r = 1.0; g = 1.0; b = 1.0; - } else { + } else if !color_overridden { let is_light = r > 0.80 && g > 0.80 && b > 0.80; let is_yellow = r > 0.80 && g > 0.70 && b < 0.30; let is_cyan = r < 0.30 && g > 0.70 && b > 0.70; @@ -457,6 +672,13 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f64, oy: f64) { b = 0.50; } } + [r, g, b] = plotted_color( + [r, g, b], + a, + screening, + options, + is_wipeout, + ); // `boundary` holds f32 offsets from the f64 `world_origin`, so resolve the // pair in f64 and only narrow once the offset has cancelled — casting // `world_origin` to f32 first re-introduces the ~0.5 m UTM quantisation the @@ -501,10 +723,17 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f64, oy: f64) { HatchPattern::Gradient { color2, .. } => { // PDF gradients are stored in resource dictionaries; for the // fast path we average the two colours, matching paper_canvas. + let second = plotted_color( + adapt_text_color([color2[0], color2[1], color2[2]]), + color2[3], + 1.0, + options, + false, + ); let avg = [ - (r + color2[0]) * 0.5, - (g + color2[1]) * 0.5, - (b + color2[2]) * 0.5, + (r + second[0]) * 0.5, + (g + second[1]) * 0.5, + (b + second[2]) * 0.5, ]; (PaintMode::Fill, avg) } @@ -616,6 +845,7 @@ fn emit_text( oy: f64, scale: f32, plot_style: Option<&PlotStyleTable>, + options: PdfPlotOptions, ) { use crate::scene::text::sdf_atlas; @@ -643,17 +873,24 @@ fn emit_text( if verts.is_empty() { continue; } - // Mirror the wire pass: CTB colour/lineweight override by ACI, and the - // `/ scale` that keeps pen widths absolute under the scaled CTM (a Fit - // plot would otherwise render text as near-invisible hairlines). + // Mirror the wire pass: indexed style color, screening, and pen width. let mut ctb_color: Option<[f32; 3]> = None; let mut lw_override: Option = None; + let mut screening = 1.0; if let Some(ctb) = plot_style { if wire.aci > 0 { ctb_color = ctb.resolve_color(wire.aci); lw_override = ctb .resolve_lineweight(wire.aci) - .map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6)); + .map(|mm| { + let divisor = if options.scale_lineweights { + 1.0 + } else { + scale.max(1e-6) + }; + (mm * MM_TO_PT).max(0.1) / divisor + }); + screening = ctb.resolve_screening(wire.aci); } } let mut gi = 0; @@ -668,9 +905,10 @@ fn emit_text( // A CTB colour override wins over the white-sheet adaptation, exactly // as in the wire pass — else a monochrome.ctb plot plots the lines // black and leaves the text on its screen colour. - let [r, g, b] = ctb_color.unwrap_or_else(|| { + let rgb = ctb_color.unwrap_or_else(|| { adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]]) }); + let [r, g, b] = plotted_color(rgb, a, screening, options, false); // Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl]. let bl = glyph_world_xy(&quad[0]); @@ -811,6 +1049,7 @@ mod tests { 2.0, Some((10.0, 10.0, 100.0, 100.0)), None, + PdfPlotOptions::default(), ); // A valid PDF is produced (starts with the PDF header) and is non-trivial. assert!(bytes.starts_with(b"%PDF"), "not a PDF"); @@ -843,8 +1082,34 @@ mod tests { let mut blank = wire.clone(); blank.text_verts.clear(); - let with_text = build_pdf(&[wire], &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, None); - let no_text = build_pdf(&[blank], &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, None); + let with_text = build_pdf( + &[wire], + &[], + &[], + 210.0, + 297.0, + 0.0, + 0.0, + 0, + 1.0, + None, + None, + PdfPlotOptions::default(), + ); + let no_text = build_pdf( + &[blank], + &[], + &[], + 210.0, + 297.0, + 0.0, + 0.0, + 0, + 1.0, + None, + None, + PdfPlotOptions::default(), + ); assert!(with_text.starts_with(b"%PDF")); assert!( with_text.len() > no_text.len(), diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index f3586d878..f7fda1104 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -1,6 +1,6 @@ //! Plot Style Table — CTB (color-based) and STB (named) file support. //! -//! CTB files map AutoCAD Color Index (ACI, 1-255) to pen properties: +//! CTB files map indexed drawing colors (ACI, 1-255) to pen properties: //! RGB color override, lineweight, and screeing percentage. //! //! File format: deflate-compressed text (key = value pairs) with @@ -13,9 +13,9 @@ use rustc_hash::FxHashMap as HashMap; use std::io::Read; use std::path::Path; -// ── AutoCAD standard lineweight table (index → mm) ─────────────────────────── +// ── Standard lineweight table (index → mm) ─────────────────────────────────── -/// Lineweight table: index value → mm, matching AutoCAD's LWEIGHT codes. +/// Lineweight table: index value → mm, matching the stored LWEIGHT codes. /// Index 0 = 0.00 mm (hairline), others follow the DXF lineweight enum. pub const LW_TABLE: &[f32] = &[ 0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.50, 0.53, 0.60, 0.70, @@ -117,6 +117,14 @@ impl PlotStyleTable { } } + /// Resolve the screening factor for the given ACI index. + pub fn resolve_screening(&self, aci: u8) -> f32 { + self.aci_entries + .get(aci as usize) + .map(|entry| entry.screening.min(100) as f32 / 100.0) + .unwrap_or(1.0) + } + // ── Internal serialisation ──────────────────────────────────────────── fn to_text(&self) -> String { @@ -256,7 +264,7 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result() { - // AutoCAD packs RGB as 0xC0RRGGBB negative int. + // The file packs RGB as a 0xC0RRGGBB negative integer. // Value 0xC2000000 (-1056964608) = use object color. if packed != -1056964608i32 { let u = packed as u32; diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index fd45793e3..d56ec6ebb 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -14,9 +14,8 @@ 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. +/// flags / `-o` options. On Windows the generated PDF already carries render +/// options, while copies and driver quality are not exposed by the shell verb. #[derive(Debug, Clone, Default)] #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub struct PrintOptions { @@ -26,29 +25,12 @@ pub struct PrintOptions { pub copies: u32, /// Force grayscale output. pub mono: bool, - /// Print quality: "Draft" | "Normal" | "High" | "Maximum". + /// Print quality label selected in the plot dialog. pub quality: Option, /// Rasterisation resolution in DPI. pub dpi: Option, -} - -// 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")] -pub async fn print_wires( - _wires: std::sync::Arc>, - _hatches: Vec, - _wipeouts: Vec, - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _plot_style: Option, -) -> Result { - Err("Printing is not available in the web version.".into()) + /// Controls applied while building the intermediate PDF. + pub render: crate::io::pdf_export::PdfPlotOptions, } #[cfg(target_arch = "wasm32")] @@ -85,45 +67,6 @@ pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Resu 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. -/// -/// Returns `Ok(printer_name)` on success or `Err(message)` on failure. -#[cfg(not(target_arch = "wasm32"))] -pub async fn print_wires( - wires: std::sync::Arc>, - hatches: Vec, - wipeouts: Vec, - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option, -) -> Result { - // ── 1. Write to a named temp file ───────────────────────────────────── - 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, - scale, - clip, - &tmp_path, - plot_style.as_ref(), - )?; - - // ── 2. Dispatch to system printer ───────────────────────────────────── - 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). @@ -151,6 +94,46 @@ pub fn list_printers() -> Vec { } } +/// Open the operating system's printer configuration surface. +#[cfg(not(target_arch = "wasm32"))] +pub fn open_printer_properties(printer: Option<&str>) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + let mut command = std::process::Command::new("control.exe"); + command.arg("printers"); + return command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open printer properties: {error}")); + } + #[cfg(target_os = "macos")] + { + let mut command = std::process::Command::new("open"); + command.arg("x-apple.systempreferences:com.apple.Print-Scan-Settings.extension"); + return command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open printer properties: {error}")); + } + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + let target = printer + .filter(|name| !name.is_empty()) + .map(|name| format!("http://localhost:631/printers/{name}")) + .unwrap_or_else(|| "http://localhost:631/printers".to_string()); + std::process::Command::new("xdg-open") + .arg(target) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open printer properties: {error}")) + } +} + +#[cfg(target_arch = "wasm32")] +pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> { + Err("Printer properties are not available in the web version.".into()) +} + /// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies, /// grayscale, quality, DPI). #[cfg(not(target_arch = "wasm32"))] @@ -183,6 +166,7 @@ pub async fn print_wires_with( clip, &tmp_path, plot_style.as_ref(), + opts.render, )?; dispatch_to_printer_opts(&tmp_path, &opts) } @@ -282,103 +266,56 @@ fn dispatch_to_printer_opts( // CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best. let pq = match q { "Draft" => "3", - "High" | "Maximum" => "5", + "Presentation" | "Maximum" => "5", _ => "4", }; cmd.arg("-o").arg(format!("print-quality={pq}")); } - let out = cmd + let lp_result = cmd .arg("--") .arg(path_str.as_ref()) - .output() - .map_err(|e| format!("Could not launch lp: {e}"))?; - if out.status.success() { + .output(); + if let Ok(out) = &lp_result { + if !out.status.success() { + // Continue to the lpr fallback below. + } else { 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 { - #[cfg(target_os = "windows")] - { - // Windows: ShellExecute with "print" verb. - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - - let path_wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); - let verb: Vec = OsStr::new("print\0").encode_wide().collect(); - let result = unsafe { - windows_sys::Win32::UI::Shell::ShellExecuteW( - std::ptr::null_mut(), - verb.as_ptr(), - path_wide.as_ptr(), - std::ptr::null(), - std::ptr::null(), - windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, - ) as usize - }; - if result > 32 { - Ok("default printer".to_string()) - } else { - Err(format!("ShellExecute PRINT failed (code {result})")) - } - } - - #[cfg(not(target_os = "windows"))] - { - // Linux / macOS: prefer `lp`, fall back to `lpr`. - let path_str = path.to_string_lossy(); - - // Try `lp` first (CUPS). - let lp = std::process::Command::new("lp") - .arg("--") - .arg(path_str.as_ref()) - .output(); - - match lp { - Ok(out) if out.status.success() => { - // `lp` prints the job ID on stdout, e.g. "request id is lp-42 (1 file(s))" - let msg = String::from_utf8_lossy(&out.stdout); - let printer = msg - .split_whitespace() - .find(|w| w.contains('-')) - .unwrap_or("default") - .to_string(); return Ok(printer); } - Ok(out) => { - let err = String::from_utf8_lossy(&out.stderr).into_owned(); - // Fall through to lpr. - if !err.is_empty() { - // Try lpr as alternative. - } - } - Err(_) => { - // lp not found — try lpr. - } } - // Fall back to `lpr`. - let lpr = std::process::Command::new("lpr") + let mut fallback = std::process::Command::new("lpr"); + if let Some(printer) = opts.printer.as_deref().filter(|name| !name.is_empty()) { + fallback.arg("-P").arg(printer); + } + if copies > 1 { + fallback.arg(format!("-#{copies}")); + } + let out = fallback .arg(path_str.as_ref()) .output() - .map_err(|e| format!("Could not launch lp or lpr: {e}"))?; - - if lpr.status.success() { - Ok("default printer".to_string()) + .map_err(|error| match lp_result { + Ok(ref lp) => format!( + "lp failed: {}; lpr could not launch: {error}", + String::from_utf8_lossy(&lp.stderr) + ), + Err(ref lp) => format!("lp could not launch: {lp}; lpr could not launch: {error}"), + })?; + if out.status.success() { + Ok(opts + .printer + .clone() + .unwrap_or_else(|| "default printer".into())) } else { - let err = String::from_utf8_lossy(&lpr.stderr).into_owned(); - Err(format!("lpr error: {err}")) + Err(format!( + "lpr failed: {}", + String::from_utf8_lossy(&out.stderr) + )) } } } diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 69e58cae5..2a72b6ccc 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -238,6 +238,7 @@ impl HatchCommand { pattern: HatchPattern::Pattern(families), name: pat_name.into(), color: [0.75, 0.75, 0.75, 0.85], + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin: origin, @@ -405,6 +406,7 @@ impl GradientCommand { }, name: self.kind.dxf_name(self.invert).into(), color: [0.30, 0.60, 0.95, 0.80], + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin: origin, @@ -608,6 +610,7 @@ impl CadCommand for BoundaryCommand { pattern: HatchPattern::Solid, name: "SOLID".into(), color: [0.45, 0.45, 0.45, 0.60], + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin: origin, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index ff8250d06..a02efd831 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1238,7 +1238,7 @@ impl Scene { // Resolve ByBlock / layer-0 inheritance for this block // child, then adapt to the background (#221). Pattern / // lineweight args are unused by a hatch's colour. - let color = crate::scene::view::render::render_style_for_block_sub( + let style = crate::scene::view::render::render_style_for_block_sub( &self.document, &EntityType::Hatch(dxf.clone()), sub_ins_color, @@ -1246,13 +1246,14 @@ impl Scene { [0.0; 8], 0.0, sub_l0, - ) - .0; + ); + let color = style.0; let color = crate::scene::view::render::adapt_to_bg(color, hatch_bg); if let Some(mut model) = Self::hatch_model_from_dxf(&dxf, color) { + model.aci = style.4; // In-block rank → within the insert's depth slot. model.draw_depth = d_base + depth_map @@ -1358,6 +1359,7 @@ impl Scene { pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color: fill_color, + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin: fill_origin, @@ -1475,6 +1477,7 @@ impl Scene { pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color: fill_color, + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin: fill_origin, @@ -2034,6 +2037,7 @@ impl Scene { // A gradient starts from its first stop; other fills use the // entity colour. color: gradient_color1.unwrap_or(color), + aci: 0, angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, world_origin, @@ -2338,6 +2342,7 @@ impl Scene { pattern: model::hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5f851ccb2..d4d78217f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3159,6 +3159,7 @@ impl Scene { pattern: crate::scene::model::hatch_model::HatchPattern::Solid, name: "SOLID".to_string(), color: self.paper_bg_color, + aci: 0, angle_offset: 0.0, scale: 1.0, // Draw-order bias is signed: entity fills/wires land in (-1, 1) @@ -4823,6 +4824,20 @@ impl Scene { self.entity_wires_arc() } + /// Return paper entities and projected model-viewport entities separately. + /// Keeping the two groups distinct lets non-GPU plotters honor the requested + /// paper/model draw order. + pub fn plot_wire_groups(&self) -> (Vec, Vec) { + if self.current_layout == "Model" { + return (self.entity_wires_arc().as_ref().clone(), Vec::new()); + } + let paper_block = self.current_layout_block_handle(); + ( + self.paper_sheet_wires_arc().as_ref().clone(), + self.viewport_content_wires(paper_block, None, None), + ) + } + /// Per-entity stable draw-order depth, keyed by entity handle value. /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 208889abe..c17e00ce6 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -177,6 +177,8 @@ pub struct HatchModel { pub name: String, /// RGBA color in [0,1]. pub color: [f32; 4], + /// Effective indexed color used by plot style tables; 0 means RGB/default. + pub aci: u8, /// Pattern rotation offset in radians (from DXF `pattern_angle`). /// Applied on top of each family's base angle at render time. pub angle_offset: f32, diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 511649355..b7715242e 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -376,7 +376,9 @@ impl Scene { } _ => model.clone(), }; - m.color = self.render_style(entity).0; + let style = self.render_style(entity); + m.color = style.0; + m.aci = style.4; if let EntityType::Hatch(dxf) = entity { // Only re-apply pattern_scale/angle for catalog-derived patterns // (empty stored lines). A pattern built from the hatch's own @@ -454,7 +456,9 @@ impl Scene { } _ => model.clone(), }; - hatch.color = self.render_style(entity).0; + let style = self.render_style(entity); + hatch.color = style.0; + hatch.aci = style.4; if let EntityType::Hatch(dxf) = entity { if let model::hatch_model::HatchPattern::Pattern(_) = &hatch.pattern { if dxf.pattern.lines.is_empty() { @@ -511,6 +515,7 @@ impl Scene { pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color: self.paper_bg_color, + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin, @@ -598,6 +603,7 @@ impl Scene { pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color: fill_color, + aci: 0, angle_offset: 0.0, scale: 1.0, world_origin: fill_origin, diff --git a/src/scene/project.rs b/src/scene/project.rs index ac7909345..c63feffd6 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -468,6 +468,7 @@ impl Scene { hatch.color, false, ); + wire.aci = hatch.aci; wire.aabb = aabb; pattern_wires.push(wire); } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 27065b297..879413f3c 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -130,6 +130,7 @@ impl canvas::Program for HatchPatternPreview { pattern: self.pattern.clone(), name: String::new(), color: [1.0; 4], + aci: 0, angle_offset: 0.0, scale: hatch_preview_scale(&self.pattern), draw_depth: 0.0, diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 44b368fc7..01c71de5b 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -7,10 +7,9 @@ use crate::app::Message; use crate::io::paper_sizes::PaperSize; use iced::widget::{ - button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, - Space, + button, checkbox, column, container, row, scrollable, text, text_input, Space, }; -use iced::{Background, Border, Element, Fit, Length, Theme}; +use iced::{Background, Border, Element, Length, Theme}; /// Sentinel entries in the printer dropdown (not real printer names). pub const OUT_DEFAULT: &str = "System default printer"; @@ -27,12 +26,12 @@ pub const SETUP_PREV: &str = ""; pub enum PlotFlag { Center, ScaleLw, + UpsideDown, Mono, Lineweights, WithStyles, Transparency, PaperspaceLast, - HidePaperspace, Stamp, SaveLayout, } @@ -44,10 +43,10 @@ pub enum PlotDlgMsg { Close, Commit, Preview, + PrinterProperties, Printer(String), Paper(String), Orientation(String), - Rotation(String), Area(String), Scale(String), Quality(String), @@ -102,8 +101,7 @@ pub struct PlotDialogState { pub paper: String, #[serde(skip)] pub orientation: String, - #[serde(skip)] - pub rotation: String, + pub upside_down: bool, pub copies: String, pub area: String, #[serde(skip)] @@ -122,11 +120,13 @@ pub struct PlotDialogState { 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, + /// The selected setup references a style table that is not loaded. + #[serde(skip)] + pub style_missing: bool, /// Named page setups in the document (refreshed when the dialog opens). #[serde(skip)] pub page_setups: Vec, @@ -139,6 +139,9 @@ pub struct PlotDialogState { /// `true` when `name_input` is renaming the selected setup, else creating. #[serde(skip)] pub name_rename: bool, + /// Whether the dialog was opened from a paper-space layout. + #[serde(skip)] + pub paper_space: bool, } impl Default for PlotDialogState { @@ -149,7 +152,7 @@ impl Default for PlotDialogState { to_file: false, paper: "A4".into(), orientation: "Landscape".into(), - rotation: "0°".into(), + upside_down: false, copies: "1".into(), area: "Window".into(), center: true, @@ -165,14 +168,15 @@ impl Default for PlotDialogState { with_styles: true, transparency: false, paperspace_last: false, - hide_paperspace: false, stamp: false, save_layout: false, style_name: String::new(), + style_missing: false, page_setups: Vec::new(), selected_setup: String::new(), name_input: None, name_rename: false, + paper_space: false, } } } @@ -186,7 +190,7 @@ impl PlotDialogState { self.to_file = o.to_file; self.paper = o.paper.clone(); self.orientation = o.orientation.clone(); - self.rotation = o.rotation.clone(); + self.upside_down = o.upside_down; self.copies = o.copies.clone(); self.area = o.area.clone(); self.center = o.center; @@ -202,10 +206,10 @@ impl PlotDialogState { self.with_styles = o.with_styles; self.transparency = o.transparency; self.paperspace_last = o.paperspace_last; - self.hide_paperspace = o.hide_paperspace; self.stamp = o.stamp; self.save_layout = o.save_layout; self.style_name = o.style_name.clone(); + self.style_missing = o.style_missing; } } @@ -274,55 +278,6 @@ fn section_label<'a>(s: &'static str) -> Element<'a, Message> { text(s).size(11).style(muted_style).into() } -fn vsep<'a>(height: Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - -/// One row in the page-setup list: a selectable name, or an inline rename field -/// when this row is being renamed. -fn setup_row<'a>( - name: &'a str, - selected: &str, - renaming: Option<&str>, - rename_buf: &'a str, -) -> Element<'a, Message> { - if renaming == Some(name) { - return text_input("", rename_buf) - .on_input(|v| Message::PlotDlg(PlotDlgMsg::NameInput(v))) - .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) - .size(11) - .padding([4, 8]) - .width(Fit) - .into(); - } - let is_sel = name == selected; - let cell = container(text(name.to_string()).size(11)) - .padding([4, 8]) - .width(Fit) - .style(move |theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: is_sel.then_some(Background::Color(palette.primary.strong.color)), - text_color: is_sel.then_some(palette.primary.strong.text), - ..Default::default() - } - }); - mouse_area(cell) - .on_press(Message::PlotDlg(PlotDlgMsg::SelectSetup(name.to_string()))) - .on_double_click(Message::PlotDlg(PlotDlgMsg::RenameStart(name.to_string()))) - .into() -} - /// A `label : dropdown` row. `ctor` turns the picked string into a dialog /// message. fn drop_row<'a>( @@ -340,7 +295,29 @@ fn drop_row<'a>( row![text(label).size(11).style(muted_style).width(92), pl] .spacing(8) .align_y(iced::Center) - .into() + .into() +} + +fn drop_row_enabled<'a>( + label: &'a str, + options: Vec, + selected: Option, + ctor: fn(String) -> PlotDlgMsg, + width: Length, + enabled: bool, +) -> Element<'a, Message> { + if enabled { + return drop_row(label, options, selected, ctor, width); + } + row![ + text(label).size(11).style(muted_style).width(92), + container(text(selected.unwrap_or_default()).size(12).style(muted_style)) + .padding([4, 7]) + .width(width), + ] + .spacing(8) + .align_y(iced::Center) + .into() } /// A `label : text field` row. @@ -363,6 +340,27 @@ fn field_row<'a>( .into() } +fn field_row_enabled<'a>( + label: &'a str, + value: &'a str, + ctor: fn(String) -> PlotDlgMsg, + width: u16, + enabled: bool, +) -> Element<'a, Message> { + if enabled { + return field_row(label, value, ctor, width); + } + row![ + text(label).size(11).style(muted_style).width(92), + container(text(value.to_string()).size(12).style(muted_style)) + .padding([4, 7]) + .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) @@ -373,6 +371,51 @@ fn check<'a>(label: &'a str, on: bool, flag: PlotFlag) -> Element<'a, Message> { .into() } +fn check_enabled<'a>( + label: &'a str, + on: bool, + flag: PlotFlag, + enabled: bool, +) -> Element<'a, Message> { + if enabled { + return check(label, on, flag); + } + checkbox(on) + .label(label) + .size(14) + .text_size(11) + .style(checkbox::primary) + .into() +} + +fn check_static<'a>(label: &'a str, on: bool) -> Element<'a, Message> { + checkbox(on) + .label(label) + .size(14) + .text_size(11) + .style(checkbox::primary) + .into() +} + +fn panel<'a>(content: impl Into>) -> Element<'a, Message> { + container(content) + .padding(10) + .width(Length::Fill) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }) + .into() +} + fn strs(items: &[&str]) -> Vec { items.iter().map(|s| s.to_string()).collect() } @@ -383,67 +426,92 @@ pub fn view_window( ) -> Element<'_, Message> { let width = sizing.width; let height = sizing.height; - // ── Toolbar: Cancel … Preview Print/Export ────────────────────────── let action = if s.to_file { "Export PDF" } else { "Print" }; - // `` / `` are pseudo-entries; layout rows are `*name*`. let is_special = s.selected_setup == SETUP_NONE || s.selected_setup == SETUP_PREV; let sel_is_layout = s.selected_setup.len() >= 2 && s.selected_setup.starts_with('*') && s.selected_setup.ends_with('*'); let can_copy = !s.selected_setup.is_empty() && !is_special; let is_named = can_copy && !sel_is_layout; - let mut left_bar = row![button(text("New").size(11)) - .on_press(Message::PlotDlg(PlotDlgMsg::NewSetup)) - .style(btn(false)) - .padding([4, 12])] - .spacing(4); + + // ── Page setup ──────────────────────────────────────────────────────── + let setup_selected = (!s.selected_setup.is_empty()).then(|| s.selected_setup.clone()); + let mut setup_actions = row![ + text("Name").size(11).style(muted_style).width(56), + iced::widget::pick_list(setup_selected, s.page_setups.clone(), |value| value.to_string()) + .on_select(|value| Message::PlotDlg(PlotDlgMsg::SelectSetup(value))) + .text_size(12) + .padding([3, 6]) + .width(Length::Fill), + button(text("Add…").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::NewSetup)) + .style(btn(false)) + .padding([4, 10]), + ] + .spacing(6) + .align_y(iced::Center); if can_copy { - left_bar = left_bar.push( + setup_actions = setup_actions.push( button(text("Copy").size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::CopySetup)) .style(btn(false)) - .padding([4, 12]), + .padding([4, 10]), ); } if is_named { - left_bar = left_bar.push( + setup_actions = setup_actions + .push( + button(text("Rename").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::RenameStart( + s.selected_setup.clone(), + ))) + .style(btn(false)) + .padding([4, 10]), + ) + .push( button(text("Delete").size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup)) .style(btn(false)) - .padding([4, 12]), - ); + .padding([4, 10]), + ); } - let toolbar = container( + let name_editor: Element<'_, Message> = if let Some(value) = s.name_input.as_deref() { row![ - left_bar, - Space::new().width(width), - button(text("Set current").size(11)) - .on_press(Message::PlotDlg(PlotDlgMsg::SetCurrent)) - .style(btn(false)) - .padding([4, 12]), - Space::new().width(6), - button(text("Preview").size(11)) - .on_press(Message::PlotDlg(PlotDlgMsg::Preview)) - .style(btn(false)) - .padding([4, 12]), - Space::new().width(6), - button(text(action).size(11)) - .on_press(Message::PlotDlg(PlotDlgMsg::Commit)) + text(if s.name_rename { "Setup name" } else { "New setup" }) + .size(11) + .style(muted_style) + .width(82), + text_input("", value) + .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) + .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) + .style(field_style) + .size(12) + .width(Length::Fill), + button(text("Save").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::NameCommit)) .style(btn(true)) - .padding([4, 18]), + .padding([4, 10]), + button(text("Cancel").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::NameCancel)) + .style(btn(false)) + .padding([4, 10]), ] - .align_y(iced::Center), - ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.weak.color - )), - ..Default::default() - }) - .width(width) - .padding([5, 10]); + .spacing(6) + .align_y(iced::Center) + .into() + } else { + Space::new().height(0).into() + }; + let setup_panel = panel( + column![ + section_label("Page setup"), + setup_actions, + name_editor, + ] + .spacing(7), + ); - // ── Printer dropdown: default + discovered printers + PDF sentinel ──── + // ── Printer / plotter ───────────────────────────────────────────────── let mut printer_opts = vec![OUT_DEFAULT.to_string()]; printer_opts.extend(s.printers.iter().cloned()); printer_opts.push(OUT_PDF.to_string()); @@ -452,132 +520,112 @@ pub fn view_window( } else { Some(s.printer.clone().unwrap_or_else(|| OUT_DEFAULT.to_string())) }; - let paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); - - // ── Left panel: named page-setup list (click selects, double-click renames) - let renaming = if s.name_rename && !s.selected_setup.is_empty() { - Some(s.selected_setup.as_str()) - } else { - None - }; - let rename_buf = s.name_input.as_deref().unwrap_or(""); - let rows: Vec> = s - .page_setups - .iter() - .map(|name| setup_row(name, &s.selected_setup, renaming, rename_buf)) - .collect(); - let list_body: Element<'_, Message> = if rows.is_empty() { - container(text("(no page setups)").size(11).style(muted_style)) - .padding([6, 8]) + let paper_note: Element<'_, Message> = if s.area == "Layout" { + text("Layout plots the current sheet; Apply to layout updates its paper size.") + .size(10) + .style(muted_style) + .width(width) .into() } else { - scrollable(column(rows).spacing(1)).height(height).into() + Space::new().height(0).into() }; - let list_panel = container( - column![ - text("Page setups").size(10).style(muted_style), - container(list_body) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }) - .width(Length::Fill) - .height(height) - .padding(2), - ] - .spacing(4) - .height(height), - ) - .width(160) - .height(height) - .padding(iced::Padding { - top: 12.0, - right: 8.0, - bottom: 12.0, - left: 12.0, - }); - - // ── Left column ────────────────────────────────────────────────────── - let style_label: String = if s.style_name.is_empty() { - "(none)".into() + let mut output_row = row![ + text("Output").size(11).style(muted_style).width(92), + iced::widget::pick_list(printer_sel, printer_opts, |value| value.to_string()) + .on_select(|value| Message::PlotDlg(PlotDlgMsg::Printer(value))) + .text_size(12) + .padding([3, 6]) + .width(Length::Fill), + ] + .spacing(8) + .align_y(iced::Center); + if !s.to_file { + output_row = output_row.push( + button(text("Properties…").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterProperties)) + .style(btn(false)) + .padding([4, 8]), + ); + } + let destination = if s.to_file { + "Destination: PDF file" + } else if s.printer.is_some() { + "Destination: selected system printer" } else { - s.style_name.clone() + "Destination: system default printer" }; - let left = column![ - section_label("Printer / plotter"), - drop_row("Output", printer_opts, printer_sel, PlotDlgMsg::Printer, width), - field_row("Copies", &s.copies, PlotDlgMsg::Copies, 60), - hdivider(width), + let printer_panel = panel( + column![ + section_label("Printer / plotter"), + output_row, + field_row_enabled("Copies", &s.copies, PlotDlgMsg::Copies, 60, !s.to_file), + text(destination).size(10).style(muted_style), + ] + .spacing(7), + ); + + // ── Paper, area, offset, scale ──────────────────────────────────────── + let paper_panel = panel(column![ section_label("Paper"), drop_row("Size", paper_opts, Some(s.paper.clone()), PlotDlgMsg::Paper, width), - drop_row( - "Orientation", - strs(&["Portrait", "Landscape"]), - Some(s.orientation.clone()), - PlotDlgMsg::Orientation, - width, - ), - drop_row( - "Rotation", - strs(&["0°", "90°", "180°", "270°"]), - Some(s.rotation.clone()), - PlotDlgMsg::Rotation, - width, - ), - hdivider(width), - section_label("Plot area"), - row![ - container( - iced::widget::pick_list( - Some(s.area.clone()), - strs(&["Layout", "Extents", "Display", "Window"]), - |value| value.to_string(), - ) - .on_select(move |v| Message::PlotDlg(PlotDlgMsg::Area(v))) - .text_size(12) - .padding([3, 6]) - .width(width) - ) - .width(width), + paper_note, + ].spacing(7)); + + let mut area_options = strs(&["Extents", "Display", "Window"]); + if s.paper_space { + area_options.insert(0, "Layout".to_string()); + } + let mut area_row = row![ + text("What to plot").size(11).style(muted_style).width(92), + iced::widget::pick_list(Some(s.area.clone()), area_options, |value| value.to_string()) + .on_select(|value| Message::PlotDlg(PlotDlgMsg::Area(value))) + .text_size(12) + .padding([3, 6]) + .width(Length::Fill), + ] + .spacing(8) + .align_y(iced::Center); + if s.area == "Window" { + area_row = area_row.push( button(text("Pick…").size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::PickWindow)) .style(btn(false)) .padding([4, 10]), - ] - .spacing(8) - .align_y(iced::Center), + ); + } + let common_area = s.area != "Layout"; + let area_panel = panel(column![ + section_label("Plot area"), + area_row, 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), + field_row_enabled("X (mm)", &s.offset_x, PlotDlgMsg::OffsetX, 70, common_area && !s.center), + field_row_enabled("Y (mm)", &s.offset_y, PlotDlgMsg::OffsetY, 70, common_area && !s.center), ] - .spacing(9), - check("Center the plot", s.center, PlotFlag::Center), - ] - .spacing(9) - .width(width); - - // ── Right column ───────────────────────────────────────────────────── - let right = column![ + .spacing(7), + check_enabled("Center the plot", s.center, PlotFlag::Center, common_area), section_label("Plot scale"), - drop_row( + drop_row_enabled( "Scale", strs(&["Fit", "1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "2:1"]), - Some(s.scale.clone()), + Some(if common_area { s.scale.clone() } else { "1:1".into() }), PlotDlgMsg::Scale, width, + common_area, ), - check("Scale lineweights", s.scale_lw, PlotFlag::ScaleLw), - hdivider(width), + check_enabled("Scale lineweights", s.scale_lw, PlotFlag::ScaleLw, common_area), + ].spacing(7)); + + // ── Style and shaded viewport settings ─────────────────────────────── + let style_label = if s.style_name.is_empty() { + "(none)".to_string() + } else if s.style_missing { + format!("{} (not loaded)", s.style_name) + } else { + s.style_name.clone() + }; + let style_panel = panel(column![ section_label("Plot style table (pen assignments)"), row![ container(text(style_label).size(12)) @@ -606,72 +654,142 @@ pub fn view_window( ] .spacing(6) .align_y(iced::Center), - hdivider(width), - section_label("Quality"), - row![ - container( - iced::widget::pick_list( - Some(s.quality.clone()), - strs(&["Draft", "Normal", "High", "Maximum"]), - |value| value.to_string(), - ) - .on_select(move |v| Message::PlotDlg(PlotDlgMsg::Quality(v))) - .text_size(12) - .padding([3, 6]) - .width(width) - ) - .width(width), - text("DPI").size(11).style(muted_style), - 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), + ].spacing(7)); + + let shaded_panel = panel(column![ + section_label("Shaded viewport options"), drop_row( - "Shaded plot", - strs(&["As displayed", "Wireframe", "Hidden", "Rendered"]), + "Shade plot", + strs(&["As displayed", "Wireframe"]), Some(s.shade.clone()), PlotDlgMsg::Shade, width, ), - hdivider(width), + drop_row( + "Quality", + strs(&["Draft", "Preview", "Normal", "Presentation", "Maximum", "Custom"]), + Some(s.quality.clone()), + PlotDlgMsg::Quality, + width, + ), + field_row_enabled("DPI", &s.dpi, PlotDlgMsg::Dpi, 70, s.quality == "Custom"), + text("Vector PDF stays resolution-independent; quality controls printer rasterization.") + .size(10) + .style(muted_style) + .width(width), + text("Hidden-line and rendered raster modes need a raster viewport backend.") + .size(10) + .style(muted_style) + .width(width), + ].spacing(7)); + + // ── Output options and orientation ──────────────────────────────────── + let options_panel = panel(column![ section_label("Plot options"), row![ column![ + check_static("Plot in background", true), check("Object lineweights", s.lineweights, PlotFlag::Lineweights), - check("Plot with styles", s.with_styles, PlotFlag::WithStyles), + check_enabled( + "Plot with styles", + s.with_styles, + PlotFlag::WithStyles, + !s.style_name.is_empty(), + ), check("Monochrome", s.mono, PlotFlag::Mono), check("Plot transparency", s.transparency, PlotFlag::Transparency), ] .spacing(6) .width(width), column![ - check("Paperspace last", s.paperspace_last, PlotFlag::PaperspaceLast), - check("Hide paperspace", s.hide_paperspace, PlotFlag::HidePaperspace), + check_enabled( + "Paper space last", + s.paperspace_last, + PlotFlag::PaperspaceLast, + s.paper_space, + ), + check_static("Hide paper objects (unavailable)", false), check("Plot stamp", s.stamp, PlotFlag::Stamp), - check("Save to layout", s.save_layout, PlotFlag::SaveLayout), + check_enabled( + "Save changes to layout", + s.save_layout, + PlotFlag::SaveLayout, + s.paper_space, + ), ] .spacing(6) .width(width), ] .spacing(10), - ] - .spacing(9) - .width(width); + ].spacing(7)); + let orientation_panel = panel(column![ + section_label("Drawing orientation"), + drop_row( + "Orientation", + strs(&["Portrait", "Landscape"]), + Some(s.orientation.clone()), + PlotDlgMsg::Orientation, + width, + ), + check("Plot upside-down", s.upside_down, PlotFlag::UpsideDown), + ].spacing(7)); + + let left = column![printer_panel, paper_panel, area_panel] + .spacing(10) + .width(width); + let right = column![style_panel, shaded_panel, options_panel, orientation_panel] + .spacing(10) + .width(width); let detail = scrollable( - container(row![left, right].spacing(18).width(width)).padding(14), + container(column![ + setup_panel, + row![left, right].spacing(12).width(width), + ].spacing(10)) + .padding(12), ) .width(width) .height(height); - let body = row![list_panel, vsep(height), detail] - .width(width) - .height(height); - container(column![toolbar, hdivider(width), body].spacing(0)) + let mut actions = row![ + button(text("Preview").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::Preview)) + .style(btn(false)) + .padding([5, 14]), + Space::new().width(Length::Fill), + ] + .spacing(6) + .align_y(iced::Center); + if s.paper_space { + actions = actions.push( + button(text("Apply to layout").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::SetCurrent)) + .style(btn(false)) + .padding([5, 12]), + ); + } + actions = actions + .push( + button(text("Cancel").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::Close)) + .style(btn(false)) + .padding([5, 14]), + ) + .push( + button(text(action).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::Commit)) + .style(btn(true)) + .padding([5, 18]), + ); + let footer = container(actions) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .padding([7, 10]) + .width(width); + + container(column![detail, hdivider(width), footer].spacing(0)) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color