feat(plot): complete plot workflow

This commit is contained in:
Hakan Seven committed 2026-07-31 12:06:12 +03:00
1 parent eb0861cf6e
commit bd1b5b4293
14 files changed
+1141 -541

No files matched your search

+343 -116
View File
@@ -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,
+12
View File
@@ -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()
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
))
}
}
}
+3
View File
@@ -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
View File
@@ -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,
+15
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -468,6 +468,7 @@ impl Scene {
hatch.color,
false,
);
wire.aci = hatch.aci;
wire.aabb = aabb;
pattern_wires.push(wire);
}
+1
View File
@@ -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
View File
@@ -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