forked from 1b-agent/opencadstudio-upstream
feat(plot): complete plot workflow
This commit is contained in:
1 parent
eb0861cf6e
commit
bd1b5b4293
14 files changed
+1141
-541
No files matched your search
+343
-116
@@ -58,6 +58,7 @@ type LayoutPlotParams = (
|
||||
std::sync::Arc<Vec<crate::scene::WireModel>>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
crate::io::pdf_export::PlotGroupSplits,
|
||||
f64,
|
||||
f64,
|
||||
f64,
|
||||
@@ -71,6 +72,7 @@ type ClippedPlotParams = (
|
||||
Vec<crate::scene::WireModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
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<crate::scene::WireModel>>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
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<Message> {
|
||||
// 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<Message> {
|
||||
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<Message> {
|
||||
// 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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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::<i16>().unwrap_or(300).clamp(1, 32767);
|
||||
}
|
||||
|
||||
self.tabs[i].dirty = true;
|
||||
@@ -2076,13 +2146,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
&mut self,
|
||||
path: std::path::PathBuf,
|
||||
) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
"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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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::<i32>()
|
||||
.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<Message> {
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
offset_x,
|
||||
@@ -2324,6 +2389,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
offset_x,
|
||||
@@ -2335,20 +2401,17 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
}
|
||||
|
||||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
||||
// 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<Message> {
|
||||
.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<Message> {
|
||||
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<Message> {
|
||||
// snapshot the just-loaded settings as the `<previous>` restore point.
|
||||
self.refresh_page_setups();
|
||||
self.plot_prev = Some(self.plot_dialog.clone());
|
||||
// Default selection: model space → <none>; 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<Message> {
|
||||
}
|
||||
|
||||
fn normalize_common_plot_dialog(&mut self) {
|
||||
let layout_rotation = self
|
||||
.plot_dialog
|
||||
.rotation
|
||||
.trim_end_matches('°')
|
||||
.parse::<i32>()
|
||||
.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<Message> {
|
||||
}
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
/// 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<Message> {
|
||||
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<Message> {
|
||||
ps.set_plot_window(x0, y0, x1, y1);
|
||||
}
|
||||
}
|
||||
ps.flags.plot_centered = d.center;
|
||||
ps.origin_x = d.offset_x.parse::<f64>().unwrap_or(0.0);
|
||||
ps.origin_y = d.offset_y.parse::<f64>().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::<f64>().unwrap_or(0.0)
|
||||
};
|
||||
ps.origin_y = if d.area == "Layout" {
|
||||
0.0
|
||||
} else {
|
||||
d.offset_y.parse::<f64>().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<Message> {
|
||||
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::<i16>().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<Message> {
|
||||
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<Message> {
|
||||
_ => "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<Message> {
|
||||
"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<Message> {
|
||||
};
|
||||
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<Message> {
|
||||
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::<f64>().unwrap_or(0.0);
|
||||
let off_y = d.offset_y.parse::<f64>().unwrap_or(0.0);
|
||||
self.apply_plot_page_settings(
|
||||
@@ -2930,16 +3106,23 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
}
|
||||
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<Message> {
|
||||
w_wires,
|
||||
w_hatches,
|
||||
w_wipeouts,
|
||||
wgroup_splits,
|
||||
sw,
|
||||
sh,
|
||||
wox,
|
||||
@@ -2985,11 +3164,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
// 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<Message> {
|
||||
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<Message> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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::<u32>().ok().filter(|value| *value > 0),
|
||||
_ => Some(300),
|
||||
};
|
||||
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),
|
||||
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<crate::io::plot_style::PlotStyleTable> {
|
||||
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<ClippedPlotParams> {
|
||||
self.area_plot_job(self.plot_window?)
|
||||
}
|
||||
@@ -3095,7 +3318,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
||||
};
|
||||
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<Message> {
|
||||
win_w as f32,
|
||||
win_h as f32,
|
||||
));
|
||||
let rotation = self
|
||||
.plot_dialog
|
||||
.rotation
|
||||
.trim_end_matches('°')
|
||||
.parse::<i32>()
|
||||
.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<Message> {
|
||||
wires,
|
||||
hatches,
|
||||
wipeouts,
|
||||
group_splits,
|
||||
page_w,
|
||||
page_h,
|
||||
offset_x,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+294
-29
@@ -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<u8> {
|
||||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||||
let mut ops: Vec<Op> = 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<f32> = 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<Op>, 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<Op>,
|
||||
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<Op>) {
|
||||
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<Op>, hatch: &HatchModel, ox: f64, oy: f64) {
|
||||
fn emit_hatch(
|
||||
ops: &mut Vec<Op>,
|
||||
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<Op>, 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<Op>, 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<Op>, 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<f32> = 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(),
|
||||
|
||||
+12
-4
@@ -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<PlotS
|
||||
let b = u8::from_str_radix(&val[5..7], 16).unwrap_or(0);
|
||||
entry.color = Some([r, g, b]);
|
||||
} else if let Ok(packed) = val.parse::<i32>() {
|
||||
// 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;
|
||||
|
||||
+77
-140
@@ -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<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")]
|
||||
pub async fn print_wires(
|
||||
_wires: std::sync::Arc<Vec<WireModel>>,
|
||||
_hatches: Vec<HatchModel>,
|
||||
_wipeouts: Vec<HatchModel>,
|
||||
_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<PlotStyleTable>,
|
||||
) -> Result<String, String> {
|
||||
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<Vec<WireModel>>,
|
||||
hatches: Vec<HatchModel>,
|
||||
wipeouts: Vec<HatchModel>,
|
||||
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<PlotStyleTable>,
|
||||
) -> Result<String, String> {
|
||||
// ── 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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: ShellExecute with "print" verb.
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let path_wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let verb: Vec<u16> = 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)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+8
-3
@@ -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,
|
||||
|
||||
@@ -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<WireModel>, Vec<WireModel>) {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
@@ -468,6 +468,7 @@ impl Scene {
|
||||
hatch.color,
|
||||
false,
|
||||
);
|
||||
wire.aci = hatch.aci;
|
||||
wire.aabb = aabb;
|
||||
pattern_wires.push(wire);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ impl canvas::Program<Message> 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,
|
||||
|
||||
+363
-245
@@ -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 = "<previous>";
|
||||
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<String>,
|
||||
@@ -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<String>,
|
||||
selected: Option<String>,
|
||||
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>>) -> 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<String> {
|
||||
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" };
|
||||
// `<none>` / `<previous>` 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<String> = 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<Element<'_, Message>> = 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
|
||||
|
||||
Reference in new issue
Block a user