feat(plot): complete plot configuration

- unify Model and Paper Space plot area behavior
- add CTB discovery, read/write, and bundled defaults
- apply scale, render mode, and output options consistently
This commit is contained in:
Hakan Seven 2026-07-31 13:41:34 +03:00
commit 325cd2474c
11 changed files with 1125 additions and 584 deletions

Binary file not shown.

BIN
assets/plotstyles/ocad.ctb Normal file

Binary file not shown.

View file

@ -647,8 +647,6 @@ pub(super) struct OpenCADStudio {
mtext_click_time: Option<Instant>,
mtext_click_off: usize,
mtext_click_count: u8,
/// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1".
plot_scale: String,
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
plot_window: Option<(f64, f64, f64, f64)>,
plot_format: crate::io::paper_sizes::PaperSize,
@ -2773,7 +2771,6 @@ impl OpenCADStudio {
mtext_click_time: None,
mtext_click_off: 0,
mtext_click_count: 0,
plot_scale: "Fit".to_string(),
plot_window: None,
plot_format: crate::io::paper_sizes::PaperSize::A4,
plot_orientation: crate::io::paper_sizes::Orientation::Landscape,
@ -2800,7 +2797,15 @@ impl OpenCADStudio {
save_dialog_for_unsaved: false,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
// Plot style
active_plot_style: None,
active_plot_style: crate::io::plot_style::PlotStyleTable::load_named(
crate::io::plot_style::DEFAULT_PLOT_STYLE,
)
.or_else(|_| {
crate::io::plot_style::PlotStyleTable::builtin(
crate::io::plot_style::DEFAULT_PLOT_STYLE,
)
})
.ok(),
// Color scheme (default: Oxocarbon)
active_theme: Theme::Oxocarbon,
ui_theme: config::UiThemeConfig::default(),

View file

@ -43,6 +43,44 @@ where
}
}
fn plot_dialog_scale_factor(d: &crate::ui::window::plot::PlotDialogState) -> f64 {
d.scales
.iter()
.find(|(name, _)| name == &d.scale)
.map(|(_, factor)| *factor)
.or_else(|| {
let (paper, drawing) = parse_plot_scale(&d.scale);
(paper > 0.0 && drawing > 0.0).then_some(paper / drawing)
})
.unwrap_or(1.0)
.max(1e-9)
}
fn scale_name_for_factor(scales: &[(String, f64)], factor: f64) -> Option<String> {
scales
.iter()
.find(|(_, candidate)| {
(*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0)
})
.map(|(name, _)| name.clone())
}
fn plot_render_mode_override(
d: &crate::ui::window::plot::PlotDialogState,
) -> Option<acadrust::entities::ViewportRenderMode> {
use acadrust::entities::ViewportRenderMode as Mode;
match d.shade.as_str() {
"2D Wireframe" => Some(Mode::Wireframe2D),
"3D Wireframe" => Some(Mode::Wireframe3D),
"Hidden Line" => Some(Mode::HiddenLine),
"Flat Shaded" => Some(Mode::FlatShaded),
"Gouraud Shaded" => Some(Mode::GouraudShaded),
"Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges),
"Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges),
_ => None,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool {
match (
@ -123,13 +161,14 @@ fn plot_content_extents(
fn plot_scene_content(
scene: &crate::scene::Scene,
paper_space_last: bool,
render_mode_override: Option<acadrust::entities::ViewportRenderMode>,
) -> (
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 (paper_wires, model_wires) = scene.plot_wire_groups();
let (paper_wires, model_wires) = scene.plot_wire_groups(render_mode_override);
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" {
@ -1984,7 +2023,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
/// Write the given plot page settings into the active layout's Layout +
/// PlotSettings objects (paper size, plot area, offset, rotation, scale).
/// No-op on the Model tab (which has no paper layout). Marks the tab dirty
/// and re-tessellates the sheet. Called by the Plot dialog on commit.
/// and re-tessellates the sheet. Called by the Plot dialog's Set current action.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_plot_page_settings(
&mut self,
@ -1995,7 +2034,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
offset_x: f64,
offset_y: f64,
rotation: i16,
scale_str: &str,
) {
let i = self.active_tab;
let dialog = self.plot_dialog.clone();
@ -2004,13 +2042,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
if layout_name != "Model" {
let w: f64 = w.max(1.0);
let h: f64 = h.max(1.0);
// Update the Layout object's limits AND its embedded
// PlotSettings fields. `paper_limits()` (sheet rendering) and
// the DWG writer both read these from the Layout, so a page
// setup that only touched a side PlotSettings object would not
// reflect on screen or survive a save. The dialog's w/h are
// the final sheet dimensions, so store them verbatim with no
// further rotation swap (#156).
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(l) = obj {
if l.name == layout_name {
@ -2021,34 +2052,27 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
l.paper_width = w;
l.paper_height = h;
l.plot_rotation = 0;
l.plot_paper_units = 1; // millimetres
l.plot_paper_units = 1;
l.plot_origin_x = offset_x;
l.plot_origin_y = offset_y;
// Custom dimensions no longer match a named size.
l.paper_size = String::new();
break;
}
}
}
// Find or create the PlotSettings object for this layout.
use acadrust::objects::{
ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType,
ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType,
ShadePlotMode, ShadePlotResolutionLevel,
};
let plot_handle =
self.tabs[i]
let plot_handle = self.tabs[i]
.scene
.document
.objects
.iter()
.find_map(|(h, obj)| {
if let ObjectType::PlotSettings(ps) = obj {
if ps.page_name == layout_name {
Some(*h)
} else {
None
}
(ps.page_name == layout_name).then_some(*h)
} else {
None
}
@ -2057,7 +2081,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let ps_entry = if let Some(h) = plot_handle {
self.tabs[i].scene.document.objects.get_mut(&h)
} else {
// Create a new PlotSettings object and insert it.
let mut ps = PlotSettings::new(layout_name.clone());
ps.handle = self.tabs[i].scene.document.allocate_handle();
let h = ps.handle;
@ -2094,15 +2117,17 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
270 => PlotRotation::Degrees270,
_ => PlotRotation::None,
};
// Apply plot scale.
use acadrust::objects::ScaledType;
let (num, den) = parse_plot_scale(scale_str);
if scale_str == "Fit" {
if dialog.fit_to_paper && plot_area != "Layout" {
ps.set_scale_to_fit();
} else {
let factor = if plot_area == "Layout" {
1.0
} else {
plot_dialog_scale_factor(&dialog)
};
ps.scale_type = ScaledType::CustomScale;
ps.scale_numerator = num;
ps.scale_denominator = den;
ps.scale_numerator = factor;
ps.scale_denominator = 1.0;
}
ps.printer_name = if dialog.to_file {
crate::ui::window::plot::OUT_PDF.into()
@ -2112,28 +2137,24 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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.plot_plot_styles = !dialog.style_name.is_empty();
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,
"2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
"Hidden Line" => ShadePlotMode::Hidden,
"As displayed" => ShadePlotMode::AsDisplayed,
_ => ShadePlotMode::Rendered,
};
ps.shade_plot_resolution = match dialog.quality.as_str() {
"Draft" => ShadePlotResolutionLevel::Draft,
"Preview" => ShadePlotResolutionLevel::Preview,
"Presentation" => ShadePlotResolutionLevel::Presentation,
"Maximum" => ShadePlotResolutionLevel::Maximum,
"Custom" => ShadePlotResolutionLevel::Custom,
"Low" => ShadePlotResolutionLevel::Draft,
"High" => ShadePlotResolutionLevel::Presentation,
_ => ShadePlotResolutionLevel::Normal,
};
ps.shade_plot_dpi =
dialog.dpi.parse::<i16>().unwrap_or(300).clamp(1, 32767);
ps.shade_plot_dpi = 300;
}
self.tabs[i].dirty = true;
// The paper sheet fill is cached by epoch, while document
// entity tessellation is unaffected by the paper size.
self.tabs[i].scene.bump_geometry_no_blocks();
self.command_line.push_info(&format!(
"Page setup: {w:.1}×{h:.1} mm area={plot_area} \
@ -2151,8 +2172,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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 || {
let work = move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
@ -2170,9 +2190,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
)
.map(|_| format!("Exported: {}", worker_path.display()))
.map_err(|e| format!("Export failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
)
};
self.run_plot_work(self.plot_dialog.background, false, work)
}
/// Export the pending Extents/Window/Display area using the same clipped
@ -2197,8 +2216,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits);
let worker_path = path.clone();
self.close_active_modal();
background_task(
move || {
let work = move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
@ -2220,9 +2238,42 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.file_name().unwrap_or_default().to_string_lossy()
)
}).map_err(|e| format!("Plot failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
)
};
self.run_plot_work(self.plot_dialog.background, false, work)
}
pub(super) fn report_plot_result(
&mut self,
result: Result<String, String>,
reopen_plot: bool,
) {
match result {
Ok(message) => self.command_line.push_info(&message),
Err(error) => self.command_line.push_error(&error),
}
if reopen_plot {
self.active_modal = Some(crate::app::ModalKind::Plot);
}
}
fn run_plot_work<F>(
&mut self,
background: bool,
reopen_plot: bool,
work: F,
) -> Task<Message>
where
F: FnOnce() -> Result<String, String> + Send + 'static,
{
if background {
background_task(work, move |result| {
Message::BackgroundIoFinished(result, reopen_plot)
})
} else {
let result = work();
self.report_plot_result(result, reopen_plot);
Task::none()
}
}
/// Build the render inputs and page geometry for a full-layout plot: wires
@ -2234,7 +2285,11 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let scene = &self.tabs[i].scene;
let paper_space = scene.current_layout != "Model";
let (source_wires, hatches, wipeouts, mut group_splits) =
plot_scene_content(scene, self.plot_dialog.paperspace_last);
plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
// 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 {
@ -2288,14 +2343,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.unwrap_or((x0, y0, x1, y1));
let content_w = (max_x - min_x).max(1e-9);
let content_h = (max_y - min_y).max(1e-9);
let scale = if self.plot_dialog.scale == "Fit" {
let scale = if self.plot_dialog.fit_to_paper {
const MARGIN: f64 = 1.05;
((paper_w / MARGIN) / content_w)
.min((paper_h / MARGIN) / content_h)
.max(1e-9)
} else {
let (num, den) = parse_plot_scale(&self.plot_dialog.scale);
((num / den) * mm_per_unit).max(1e-9)
(plot_dialog_scale_factor(&self.plot_dialog) * mm_per_unit).max(1e-9)
};
let target_x = if centered {
(paper_w - content_w * scale) * 0.5
@ -2505,12 +2559,29 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
/// layout's plot settings and the printers found on the system.
pub(super) fn on_plot_dialog_open(&mut self) -> Task<Message> {
use crate::io::paper_sizes::Orientation;
let scales: Vec<(String, f64)> = self.tabs[self.active_tab]
.scene
.scale_list()
.into_iter()
.map(|(name, _, factor)| (name, factor))
.collect();
let one_to_one = scale_name_for_factor(&scales, 1.0)
.or_else(|| scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".into());
// Keep the user's last print preferences (printer, copies, quality,
// output options — persisted in `self.plot_dialog`); only refresh the
// runtime printer list and reseed the drawing-specific fields from the
// active layout.
let d = &mut self.plot_dialog;
d.printers = crate::io::print_to_printer::list_printers();
d.plot_styles = crate::io::plot_style::available_ctb_names();
d.scales = scales;
if d.scale.eq_ignore_ascii_case("fit") {
d.fit_to_paper = true;
d.scale = one_to_one.clone();
} else if !d.scales.iter().any(|(name, _)| name == &d.scale) {
d.scale = one_to_one;
}
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 {
@ -2518,14 +2589,32 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
Orientation::Landscape => "Landscape",
}
.to_string();
d.quality = match d.quality.as_str() {
"Low" | "Draft" => "Low",
"High" | "Presentation" | "Maximum" | "Custom" => "High",
_ => "Normal",
}
.into();
d.shade = match d.shade.as_str() {
"Wireframe" => "2D Wireframe",
value if value.starts_with("As displayed") => "As displayed",
"2D Wireframe"
| "3D Wireframe"
| "Hidden Line"
| "Flat Shaded"
| "Gouraud Shaded"
| "Flat Shaded + Edges"
| "Gouraud Shaded + Edges" => d.shade.as_str(),
_ => "As displayed",
}
.into();
d.style_name = self
.active_plot_style
.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),
// `area`, scale and fit are remembered user choices,
// not reseeded from the layout. Offset / center / rotation ARE layout
// properties, so reflect them.
if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() {
@ -2569,7 +2658,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.plot_dialog.center = true;
self.plot_dialog.offset_x = "0.0".into();
self.plot_dialog.offset_y = "0.0".into();
self.plot_dialog.scale = "Fit".into();
self.plot_dialog.fit_to_paper = true;
}
/// Handle one edit / action from the Plot dialog.
@ -2577,7 +2666,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
&mut self,
msg: crate::ui::window::plot::PlotDlgMsg,
) -> Task<Message> {
use crate::ui::window::plot::{PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF};
use crate::ui::window::plot::{
PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, STYLE_NONE,
};
match msg {
M::Close => {
self.close_active_modal();
@ -2625,7 +2716,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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.fit_to_paper = false;
self.plot_dialog.scale = scale_name_for_factor(
&self.plot_dialog.scales,
1.0,
)
.unwrap_or_else(|| "1:1".into());
}
self.plot_dialog.area = s;
Task::none()
@ -2637,15 +2733,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
Task::none()
}
M::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();
self.plot_dialog.quality = s;
Task::none()
}
M::Shade(s) => {
@ -2664,39 +2752,50 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.plot_dialog.offset_y = s;
Task::none()
}
M::Dpi(s) => {
self.plot_dialog.dpi = s;
Task::none()
}
M::Flag(f) => {
let d = &mut self.plot_dialog;
match f {
PlotFlag::Background => d.background = !d.background,
PlotFlag::MergeLines => d.merge_lines = !d.merge_lines,
PlotFlag::FitToPaper if d.area != "Layout" => {
d.fit_to_paper = !d.fit_to_paper
}
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 if !d.style_name.is_empty() => {
d.with_styles = !d.with_styles
}
PlotFlag::Transparency => d.transparency = !d.transparency,
PlotFlag::PaperspaceLast if d.paper_space => {
d.paperspace_last = !d.paperspace_last
}
PlotFlag::Stamp => d.stamp = !d.stamp,
PlotFlag::SaveLayout if d.paper_space => d.save_layout = !d.save_layout,
_ => {}
}
Task::none()
}
M::LoadStyle => Task::done(Message::PlotStyleLoad),
M::ClearStyle => {
M::SaveStyle => Task::done(Message::PlotStylePanelSave),
M::Style(name) => {
if name == STYLE_NONE {
self.active_plot_style = None;
self.plot_dialog.style_name.clear();
self.plot_dialog.style_missing = false;
self.plot_dialog.with_styles = false;
} else {
match crate::io::plot_style::PlotStyleTable::load_named(&name) {
Ok(table) => {
self.plot_dialog.style_name = table.name.clone();
self.plot_dialog.style_missing = false;
self.active_plot_style = Some(table);
}
Err(error) => {
self.plot_dialog.style_name = name;
self.plot_dialog.style_missing = true;
self.command_line.push_error(&error);
}
}
}
Task::none()
}
M::PickWindow => {
@ -2846,7 +2945,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
d.offset_x = "0.0".into();
d.offset_y = "0.0".into();
d.upside_down = false;
d.scale = "Fit".into();
d.fit_to_paper = is_model;
d.scale = scale_name_for_factor(&d.scales, 1.0)
.or_else(|| d.scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".into());
} else if name == SETUP_PREV {
if let Some(prev) = self.plot_prev.clone() {
self.plot_dialog.copy_settings_from(&prev);
@ -2935,13 +3037,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
};
if d.area == "Layout" {
ps.set_standard_scale(ScaledType::OneToOne);
} else if d.scale == "Fit" {
} else if d.fit_to_paper {
ps.set_scale_to_fit();
} else {
let (num, den) = parse_plot_scale(&d.scale);
let factor = plot_dialog_scale_factor(d);
ps.scale_type = ScaledType::CustomScale;
ps.scale_numerator = num;
ps.scale_denominator = den;
ps.scale_numerator = factor;
ps.scale_denominator = 1.0;
}
ps.printer_name = if d.to_file {
crate::ui::window::plot::OUT_PDF.into()
@ -2951,22 +3053,21 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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.plot_plot_styles = !d.style_name.is_empty();
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,
"2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
"Hidden Line" => ShadePlotMode::Hidden,
"As displayed" => ShadePlotMode::AsDisplayed,
_ => ShadePlotMode::Rendered,
};
ps.shade_plot_resolution = match d.quality.as_str() {
"Draft" => ShadePlotResolutionLevel::Draft,
"Preview" => ShadePlotResolutionLevel::Preview,
"Presentation" => ShadePlotResolutionLevel::Presentation,
"Maximum" => ShadePlotResolutionLevel::Maximum,
"Custom" => ShadePlotResolutionLevel::Custom,
"Low" => ShadePlotResolutionLevel::Draft,
"High" => ShadePlotResolutionLevel::Presentation,
_ => ShadePlotResolutionLevel::Normal,
};
ps.shade_plot_dpi = d.dpi.parse::<i16>().unwrap_or(300).clamp(1, 32767);
ps.shade_plot_dpi = 300;
ps
}
@ -2983,11 +3084,33 @@ 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()
if !ps.current_style_sheet.is_empty()
&& self
.active_plot_style
.as_ref()
.is_some_and(|table| table.name == ps.current_style_sheet);
.is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet))
{
if let Ok(table) = crate::io::plot_style::PlotStyleTable::load_named(
&ps.current_style_sheet,
) {
self.active_plot_style = Some(table);
}
}
let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone());
let style_name = if ps.current_style_sheet.is_empty() {
active_style_name.clone().unwrap_or_default()
} else if active_style_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(&ps.current_style_sheet))
{
active_style_name.clone().unwrap_or_default()
} else {
ps.current_style_sheet.clone()
};
let style_loaded = !style_name.is_empty()
&& active_style_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(&style_name));
let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height);
let d = &mut self.plot_dialog;
d.paper = paper;
@ -3015,44 +3138,38 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.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()
d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit();
let target_factor = if d.area == "Layout" {
1.0
} else if ps.scale_denominator.abs() > 1e-9 {
ps.scale_numerator / ps.scale_denominator
} else {
let n = ps.scale_numerator;
let m = ps.scale_denominator;
if (n - 1.0).abs() < 1e-9 {
format!("1:{}", m as i64)
} else if (m - 1.0).abs() < 1e-9 {
format!("{}:1", n as i64)
} else {
"Fit".into()
}
1.0
};
if !d.fit_to_paper || !d.scales.iter().any(|(name, _)| name == &d.scale) {
d.scale = scale_name_for_factor(&d.scales, target_factor)
.or_else(|| scale_name_for_factor(&d.scales, 1.0))
.or_else(|| d.scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".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",
ShadePlotMode::Wireframe => "2D Wireframe",
ShadePlotMode::Hidden => "Hidden Line",
ShadePlotMode::Rendered => "Gouraud Shaded",
ShadePlotMode::AsDisplayed => "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",
ShadePlotResolutionLevel::Draft => "Low",
ShadePlotResolutionLevel::Presentation
| ShadePlotResolutionLevel::Maximum
| ShadePlotResolutionLevel::Custom => "High",
_ => "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;
@ -3060,7 +3177,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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_name = style_name;
d.style_missing = !d.style_name.is_empty() && !style_loaded;
}
@ -3083,11 +3200,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
};
self.plot_format = paper;
self.plot_orientation = orient;
self.plot_scale = if d.area == "Layout" {
"1:1".into()
} else {
d.scale.clone()
};
}
fn apply_dialog_to_layout(&mut self) {
@ -3106,15 +3218,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
off_x,
off_y,
rotation,
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 {
if d.style_missing {
self.command_line.push_error(&format!(
"Plot style table '{}' is not loaded.",
d.style_name
@ -3123,13 +3233,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
// Remember the user's print preferences across sessions.
self.save_config();
// 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.
// Preview and normal printing must not resize/mutate the live layout;
// the runtime paper/scale choices still drive this one plot operation.
self.sync_dialog_plot_runtime();
if !preview && d.save_layout {
self.apply_dialog_to_layout();
}
self.active_modal = None;
let plot_style = self.dialog_plot_style(&d);
@ -3165,17 +3271,15 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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 || {
let work = 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}"))
},
|result| Message::BackgroundIoFinished(result, true),
);
};
return self.run_plot_work(d.background, true, work);
}
if d.to_file {
// Tested clipped export (opens a save dialog).
@ -3184,8 +3288,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf");
let render_options = Self::pdf_plot_options(&d, wgroup_splits);
let opts = self.plot_print_options(&d, wgroup_splits);
return background_task(
move || {
let work = 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(),
@ -3193,9 +3296,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
).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}"))
},
|result| Message::BackgroundIoFinished(result, false),
);
};
return self.run_plot_work(d.background, false, work);
}
let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) =
@ -3204,8 +3306,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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 || {
let work = move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
@ -3222,9 +3323,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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}"))
},
|result| Message::BackgroundIoFinished(result, true),
);
};
return self.run_plot_work(d.background, true, work);
}
if d.to_file {
@ -3234,16 +3334,16 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let opts = self.plot_print_options(&d, group_splits);
self.command_line.push_info("Sending to system printer…");
background_task(
move || {
let work = move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires_with(
wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip,
plot_style, opts,
))
},
Message::PrintResult,
)
.map(|printer| format!("Sent to printer: {printer}"))
.map_err(|error| format!("Print failed: {error}"))
};
self.run_plot_work(d.background, false, work)
}
/// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from
@ -3253,20 +3353,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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,
render: Self::pdf_plot_options(d, group_splits),
}
}
@ -3279,9 +3369,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
object_lineweights: d.lineweights,
scale_lineweights: d.scale_lw,
transparency: d.transparency,
monochrome: d.mono,
wireframe: d.shade == "Wireframe",
stamp: d.stamp,
merge_lines: d.merge_lines,
group_splits,
}
}
@ -3290,12 +3379,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
&self,
d: &crate::ui::window::plot::PlotDialogState,
) -> Option<crate::io::plot_style::PlotStyleTable> {
if !d.with_styles || d.style_missing {
if d.style_name.is_empty() || d.style_missing {
return None;
}
self.active_plot_style
.as_ref()
.filter(|table| table.name == d.style_name)
.filter(|table| table.name.eq_ignore_ascii_case(&d.style_name))
.cloned()
}
@ -3319,7 +3408,11 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
));
}
let (wires, hatches, wipeouts, _) =
plot_scene_content(scene, self.plot_dialog.paperspace_last);
plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
let extents = plot_content_extents(
&wires
.iter()
@ -3400,17 +3493,17 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation);
let win_w = (x1 - x0).max(1e-9);
let win_h = (y1 - y0).max(1e-9);
let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") {
let scale_sel = if self.plot_dialog.fit_to_paper {
PlotScale::Fit
} else {
let (num, den) = parse_plot_scale(&self.plot_scale);
if num > 0.0 && den > 0.0 {
let factor = plot_dialog_scale_factor(&self.plot_dialog);
if factor > 0.0 {
let mm_per_unit = if self.tabs[i].scene.current_layout == "Model" {
1.0
} else {
1.0 / self.tabs[i].scene.paper_space_unit_factor().max(1e-9)
};
PlotScale::Ratio((num / den) * mm_per_unit)
PlotScale::Ratio(factor * mm_per_unit)
} else {
PlotScale::Fit
}
@ -3430,7 +3523,11 @@ 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, mut group_splits) =
plot_scene_content(scene, self.plot_dialog.paperspace_last);
plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())]
.iter()
.filter(|w| w.name != "paper_printable_area")
@ -3532,13 +3629,16 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.unwrap_or("export.ctb".into());
Task::perform(
async move {
crate::sys::file_dialog()
let mut dialog = crate::sys::file_dialog()
.set_title("Save Plot Style Table")
.set_file_name(&default_name)
.add_filter("Plot Style Files", &["ctb", "stb", "CTB", "STB"])
.add_filter("All Files", &["*"])
.save_file()
.await
.add_filter("Plot Style Files", &["ctb", "CTB"])
.add_filter("All Files", &["*"]);
#[cfg(not(target_arch = "wasm32"))]
if let Ok(dir) = crate::io::plot_style::ensure_plot_styles_dir() {
dialog = dialog.set_directory(dir);
}
dialog.save_file().await
.map(|h| crate::sys::handle_path(&h))
},
Message::PlotStylePanelSavePath,

View file

@ -5176,7 +5176,6 @@ impl OpenCADStudio {
}
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,
@ -5187,6 +5186,7 @@ impl OpenCADStudio {
.count()
));
self.active_plot_style = Some(table);
self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names();
Task::none()
}
Message::PlotStyleLoaded(None) => Task::none(),
@ -5194,7 +5194,6 @@ impl OpenCADStudio {
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()
}
@ -5264,12 +5263,34 @@ impl OpenCADStudio {
Message::PlotStylePanelSave => self.on_plot_style_panel_save(),
Message::PlotStylePanelSavePath(Some(path)) => {
let path = if path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ctb"))
{
path
} else {
path.with_extension("ctb")
};
if let Some(table) = &self.active_plot_style {
match table.save(&path) {
Ok(()) => self.command_line.push_output(&format!(
Ok(()) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
if let Some(table) = self.active_plot_style.as_mut() {
table.name = name.clone();
}
self.plot_dialog.style_name = name;
self.plot_dialog.style_missing = false;
self.plot_dialog.plot_styles =
crate::io::plot_style::available_ctb_names();
self.command_line.push_output(&format!(
"Plot style table saved to \"{}\".",
path.display()
)),
));
}
Err(e) => self.command_line.push_error(&format!("Save error: {e}")),
}
}

View file

@ -776,14 +776,16 @@ mod save_failure_tests {
/// Show a file-open dialog and load the selected CTB or STB file.
pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
let handle = crate::sys::file_dialog()
let mut dialog = crate::sys::file_dialog()
.set_title("Load Plot Style Table")
.add_filter("Plot Style Tables", &["ctb", "stb", "CTB", "STB"])
.add_filter("Plot Style Tables", &["ctb", "CTB"])
.add_filter("CTB Files", &["ctb", "CTB"])
.add_filter("STB Files", &["stb", "STB"])
.add_filter("All Files", &["*"])
.pick_file()
.await?;
.add_filter("All Files", &["*"]);
#[cfg(not(target_arch = "wasm32"))]
if let Ok(dir) = plot_style::ensure_plot_styles_dir() {
dialog = dialog.set_directory(dir);
}
let handle = dialog.pick_file().await?;
plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
}

View file

@ -15,9 +15,10 @@ use crate::scene::model::hatch_model::HatchPattern;
use crate::scene::WireModel;
#[cfg(not(target_arch = "wasm32"))]
use printpdf::{
BuiltinFont, Color, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op,
PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing,
Pt, Rgb, TextItem, WindingOrder,
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, 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;
@ -67,9 +68,8 @@ pub struct PdfPlotOptions {
pub object_lineweights: bool,
pub scale_lineweights: bool,
pub transparency: bool,
pub monochrome: bool,
pub wireframe: bool,
pub stamp: bool,
pub merge_lines: bool,
pub group_splits: PlotGroupSplits,
}
@ -87,9 +87,8 @@ impl Default for PdfPlotOptions {
object_lineweights: true,
scale_lineweights: false,
transparency: false,
monochrome: false,
wireframe: false,
stamp: false,
merge_lines: false,
group_splits: PlotGroupSplits::default(),
}
}
@ -195,6 +194,20 @@ fn build_pdf(
rectangle: printpdf::Rect::from_wh(Mm(paper_w).into(), Mm(paper_h).into()),
});
let normal_blend = if options.merge_lines {
let merge = doc.add_graphics_state(
ExtendedGraphicsState::default().with_blend_mode(BlendMode::multiply()),
);
let normal = doc.add_graphics_state(
ExtendedGraphicsState::default().with_blend_mode(BlendMode::normal()),
);
ops.push(Op::SaveGraphicsState);
ops.push(Op::LoadGraphicsState { gs: merge });
Some(normal)
} else {
None
};
// Round line caps/joins for CAD aesthetics.
ops.push(Op::SetLineCapStyle {
cap: LineCapStyle::Round,
@ -283,7 +296,15 @@ fn build_pdf(
// 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, plot_style, options);
emit_hatch(
&mut ops,
hatch,
ox,
oy,
plot_style,
options,
normal_blend.as_ref(),
);
}
let mut last_color: Option<[f32; 3]> = None;
@ -337,7 +358,7 @@ fn build_pdf(
b = 0.50;
}
}
[r, g, b] = plotted_color([r, g, b], a, screening, options, false);
[r, g, b] = plotted_color([r, g, b], a, screening, options);
if last_color
.map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01)
@ -437,6 +458,9 @@ fn build_pdf(
if needs_state {
ops.push(Op::RestoreGraphicsState);
}
if options.merge_lines {
ops.push(Op::RestoreGraphicsState);
}
if options.stamp {
emit_plot_stamp(&mut ops);
}
@ -488,15 +512,11 @@ fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint]) {
#[cfg(not(target_arch = "wasm32"))]
fn plotted_color(
mut rgb: [f32; 3],
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)
@ -520,7 +540,7 @@ fn emit_wire_fills(
options: PdfPlotOptions,
) {
for wire in wires {
if wire.fill_tris.is_empty() || (options.wireframe && wire.fill_is_3d) {
if wire.fill_tris.is_empty() {
continue;
}
let [mut r, mut g, mut b, a] = wire.color;
@ -541,7 +561,7 @@ fn emit_wire_fills(
if !color_overridden {
[r, g, b] = adapt_text_color([r, g, b]);
}
[r, g, b] = plotted_color([r, g, b], a, screening, options, false);
[r, g, b] = plotted_color([r, g, b], a, screening, options);
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r,
@ -623,6 +643,7 @@ fn emit_hatch(
oy: f64,
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
normal_blend: Option<&ExtendedGraphicsStateId>,
) {
if hatch.boundary.is_empty() {
return;
@ -672,13 +693,7 @@ fn emit_hatch(
b = 0.50;
}
}
[r, g, b] = plotted_color(
[r, g, b],
a,
screening,
options,
is_wipeout,
);
[r, g, b] = plotted_color([r, g, b], a, screening, options);
// `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
@ -728,7 +743,6 @@ fn emit_hatch(
color2[3],
1.0,
options,
false,
);
let avg = [
(r + second[0]) * 0.5,
@ -789,6 +803,12 @@ fn emit_hatch(
}),
});
}
if is_wipeout {
if let Some(gs) = normal_blend {
ops.push(Op::SaveGraphicsState);
ops.push(Op::LoadGraphicsState { gs: gs.clone() });
}
}
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings,
@ -796,6 +816,9 @@ fn emit_hatch(
winding_order: WindingOrder::EvenOdd,
},
});
if is_wipeout && normal_blend.is_some() {
ops.push(Op::RestoreGraphicsState);
}
}
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
@ -908,7 +931,7 @@ fn emit_text(
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);
let [r, g, b] = plotted_color(rgb, a, screening, options);
// Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl].
let bl = glyph_world_xy(&quad[0]);

View file

@ -3,23 +3,84 @@
//! 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
//! 255 `begin_plot_style … end_plot_style` blocks.
//! File format: a fixed 60-byte header followed by zlib-compressed text.
//!
//! STB files follow the same format but use named styles instead of
//! ACI indices; they are read into a `Vec<NamedPlotStyle>`.
use rustc_hash::FxHashMap as HashMap;
use std::io::Read;
use std::path::Path;
use std::path::{Component, Path, PathBuf};
pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb";
pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb";
#[cfg(not(target_arch = "wasm32"))]
pub fn plot_styles_dir() -> Result<PathBuf, String> {
crate::config::config_dir()
.map(|path| path.join("plotstyles"))
.ok_or_else(|| "Plot styles folder could not be resolved".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn ensure_plot_styles_dir() -> Result<PathBuf, String> {
let dir = plot_styles_dir()?;
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
for (name, bytes) in [
(
DEFAULT_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/ocad.ctb").as_slice(),
),
(
MONOCHROME_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/monochrome.ctb").as_slice(),
),
] {
let path = dir.join(name);
if !path.exists() {
std::fs::write(path, bytes).map_err(|error| error.to_string())?;
}
}
Ok(dir)
}
/// CTB files available to the Plot dialog.
pub fn available_ctb_names() -> Vec<String> {
#[cfg(not(target_arch = "wasm32"))]
{
let Ok(dir) = ensure_plot_styles_dir() else {
return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
};
let Ok(entries) = std::fs::read_dir(dir) else {
return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
};
let mut names: Vec<String> = entries
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
.filter_map(|entry| {
let path = entry.path();
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
.then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect();
names.sort_by_key(|name| name.to_ascii_lowercase());
names.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
names
}
#[cfg(target_arch = "wasm32")]
{
vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]
}
}
// ── Standard lineweight table (index → mm) ───────────────────────────────────
/// 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,
0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
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.45, 0.50,
0.53, 0.60, 0.65, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
];
// ── Per-color entry ───────────────────────────────────────────────────────────
@ -27,24 +88,45 @@ pub const LW_TABLE: &[f32] = &[
/// A single entry in a CTB or STB plot style table.
#[derive(Debug, Clone)]
pub struct PlotStyleEntry {
/// Optional display name (empty for CTB; the style name for STB).
pub name: String,
pub localized_name: String,
pub description: String,
/// If `Some([r,g,b])`, override the entity color with this RGB value (0..255).
/// If `None`, use the object color.
pub color: Option<[u8; 3]>,
/// Lineweight index into `LW_TABLE`. 255 = use object lineweight.
/// Lineweight index into the table. 0 (and legacy 255) = object lineweight.
pub lineweight: u8,
/// Screen percentage 0100 (100 = opaque).
pub screening: u8,
pub color_policy: u8,
pub physical_pen_number: u16,
pub virtual_pen_number: u16,
pub linepattern_size: f32,
pub linetype: u8,
pub adaptive_linetype: bool,
pub fill_style: u8,
pub end_style: u8,
pub join_style: u8,
}
impl Default for PlotStyleEntry {
fn default() -> Self {
PlotStyleEntry {
name: String::new(),
localized_name: String::new(),
description: String::new(),
color: None,
lineweight: 255, // use object lineweight
lineweight: 0,
screening: 100,
color_policy: 1,
physical_pen_number: 0,
virtual_pen_number: 0,
linepattern_size: 0.5,
linetype: 31,
adaptive_linetype: true,
fill_style: 73,
end_style: 4,
join_style: 5,
}
}
}
@ -59,6 +141,11 @@ pub struct PlotStyleTable {
pub name: String,
/// Whether this is a named-style (STB) table rather than color-based (CTB).
pub is_stb: bool,
pub description: String,
pub scale_factor: f32,
pub apply_factor: bool,
pub custom_lineweight_display_units: u8,
pub lineweights: Vec<f32>,
/// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid).
pub aci_entries: Vec<PlotStyleEntry>, // 256 entries, index = ACI
/// For STB: named style entries.
@ -71,6 +158,11 @@ impl PlotStyleTable {
PlotStyleTable {
name: name.into(),
is_stb: false,
description: String::new(),
scale_factor: 1.0,
apply_factor: false,
custom_lineweight_display_units: 0,
lineweights: LW_TABLE.to_vec(),
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
named_entries: HashMap::default(),
}
@ -84,11 +176,63 @@ impl PlotStyleTable {
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let is_stb = name.to_lowercase().ends_with(".stb");
let text = decompress_ctb(&raw)?;
Self::from_bytes(name, &raw)
}
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
let name = name.into();
let is_stb = name.to_ascii_lowercase().ends_with(".stb");
let text = decompress_ctb(raw)?;
parse_plot_style_text(&text, name, is_stb)
}
pub fn builtin(name: &str) -> Result<Self, String> {
match name.to_ascii_lowercase().as_str() {
DEFAULT_PLOT_STYLE => Self::from_bytes(
DEFAULT_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/ocad.ctb"),
),
MONOCHROME_PLOT_STYLE => Self::from_bytes(
MONOCHROME_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/monochrome.ctb"),
),
_ => Err(format!("Unknown built-in plot style: {name}")),
}
}
/// Load one CTB by file name from the per-user plot styles folder.
pub fn load_named(name: &str) -> Result<Self, String> {
let path = Path::new(name);
if path.components().count() != 1
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|| !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
{
return Err(format!("Invalid plot style name: {name}"));
}
#[cfg(not(target_arch = "wasm32"))]
{
let dir = ensure_plot_styles_dir()?;
let matched = std::fs::read_dir(&dir)
.map_err(|error| error.to_string())?
.filter_map(Result::ok)
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(name)
})
.map(|entry| entry.path())
.ok_or_else(|| format!("Plot style not found: {name}"))?;
return Self::load(&matched);
}
#[cfg(target_arch = "wasm32")]
Self::builtin(name)
}
/// Write this table to disk as a CTB/STB file.
#[allow(dead_code)]
pub fn save(&self, path: &Path) -> Result<(), String> {
@ -110,10 +254,10 @@ impl PlotStyleTable {
/// Returns None if no override (use object lineweight).
pub fn resolve_lineweight(&self, aci: u8) -> Option<f32> {
let entry = self.aci_entries.get(aci as usize)?;
if entry.lineweight == 255 {
if matches!(entry.lineweight, 0 | 255) {
None
} else {
LW_TABLE.get(entry.lineweight as usize).copied()
self.lineweights.get(entry.lineweight as usize).copied()
}
}
@ -129,31 +273,69 @@ impl PlotStyleTable {
fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("description=\n");
s.push_str("apply_factor=0\n");
s.push_str("unit_type=1\n");
s.push_str("custom_lineweight_display_units=0\n");
for (_idx, entry) in self.aci_entries.iter().enumerate().skip(1) {
s.push_str("begin_plot_style\n");
s.push_str(&format!(" description={}\n", entry.description));
s.push_str(" physical_pen_number=0\n");
s.push_str(" virtual_pen_number=0\n");
s.push_str(&format!(" screen={}\n", entry.screening));
s.push_str(" linepattern_size=0.5\n");
s.push_str(" linetype=31\n");
s.push_str(" adaptive_linetype=TRUE\n");
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
s.push_str(" fill_style=64\n");
s.push_str(" end_style=0\n");
s.push_str(" join_style=0\n");
if let Some([r, g, b]) = entry.color {
s.push_str(&format!(" color1=#{:02X}{:02X}{:02X}\n", r, g, b));
let description = self.description.replace(['\r', '\n'], " ");
s.push_str(&format!("description=\"{description}\n"));
s.push_str("aci_table_available=TRUE\n");
s.push_str(&format!("scale_factor={:.1}\n", self.scale_factor));
s.push_str(&format!(
"apply_factor={}\n",
if self.apply_factor { "TRUE" } else { "FALSE" }
));
s.push_str(&format!(
"custom_lineweight_display_units={}\n",
self.custom_lineweight_display_units
));
s.push_str("aci_table{\n");
for index in 0..255 {
s.push_str(&format!(" {index}=\"Color_{}\n", index + 1));
}
s.push_str("}\nplot_style{\n");
for (index, entry) in self.aci_entries.iter().enumerate().skip(1).take(255) {
let style_index = index - 1;
let style_name = if entry.name.is_empty() {
format!("Color_{index}")
} else {
// 0xC2000000 = "use object color"
s.push_str(" color1=-1056964608\n");
entry.name.replace(['\r', '\n'], " ")
};
let localized_name = if entry.localized_name.is_empty() {
style_name.clone()
} else {
entry.localized_name.replace(['\r', '\n'], " ")
};
let description = entry.description.replace(['\r', '\n'], " ");
s.push_str(&format!(" {style_index}{{\n"));
s.push_str(&format!(" name=\"{style_name}\n"));
s.push_str(&format!(" localized_name=\"{localized_name}\n"));
s.push_str(&format!(" description=\"{description}\n"));
if let Some(rgb) = entry.color {
let packed = packed_rgb(rgb);
s.push_str(&format!(" color={packed}\n mode_color={packed}\n"));
} else {
s.push_str(" color=-1\n");
}
s.push_str("end_plot_style\n");
s.push_str(&format!(" color_policy={}\n", entry.color_policy));
s.push_str(&format!(
" physical_pen_number={}\n virtual_pen_number={}\n",
entry.physical_pen_number, entry.virtual_pen_number
));
s.push_str(&format!(" screen={}\n", entry.screening));
s.push_str(&format!(
" linepattern_size={}\n linetype={}\n adaptive_linetype={}\n",
entry.linepattern_size,
entry.linetype,
if entry.adaptive_linetype { "TRUE" } else { "FALSE" }
));
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
s.push_str(&format!(
" fill_style={}\n end_style={}\n join_style={}\n }}\n",
entry.fill_style, entry.end_style, entry.join_style
));
}
s.push_str("}\ncustom_lineweight_table{\n");
for (index, weight) in self.lineweights.iter().enumerate() {
s.push_str(&format!(" {index}={weight:.2}\n"));
}
s.push_str("}\n");
s
}
}
@ -162,72 +344,290 @@ impl PlotStyleTable {
/// Decompress a CTB/STB file's raw bytes into the text content.
///
/// CTB files start with a plain-text header (first line: "PIAFILEVERSION_2.0")
/// followed by raw-deflate compressed content. Some tools write pure zlib
/// (with the two-byte zlib header 0x78 0x9C) instead — we handle both.
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
// Find the first newline — everything after it is the compressed payload.
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
let mut decoded = Vec::new();
if data.starts_with(PREFIX) {
if data.len() < 60 {
return Err("CTB header is truncated".into());
}
let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap());
let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize;
let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize;
if compressed_len > data.len() - 60 {
return Err("CTB compressed payload is truncated".into());
}
let payload = &data[60..60 + compressed_len];
if adler32(payload) != checksum {
return Err("CTB compressed payload checksum mismatch".into());
}
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("CTB zlib decompress: {e}"))?;
if decoded.len() != text_len {
return Err(format!(
"CTB content length mismatch: expected {text_len}, got {}",
decoded.len()
));
}
} else {
let split_at = data
.iter()
.position(|&b| b == b'\n')
.map(|p| p + 1)
.unwrap_or(0);
let payload = &data[split_at..];
// Try zlib (0x78 prefix) first, then raw deflate.
let mut text = String::new();
if payload.starts_with(&[0x78]) {
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_string(&mut text)
.map_err(|e| format!("zlib decompress: {e}"))?;
.read_to_end(&mut decoded)
.map_err(|e| format!("legacy CTB zlib decompress: {e}"))?;
} else {
use flate2::read::DeflateDecoder;
DeflateDecoder::new(payload)
.read_to_string(&mut text)
.map_err(|e| format!("deflate decompress: {e}"))?;
.read_to_end(&mut decoded)
.map_err(|e| format!("legacy CTB deflate decompress: {e}"))?;
}
Ok(text)
}
if decoded.last() == Some(&0) {
decoded.pop();
}
String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))
}
/// Compress plot-style text content as a CTB/STB file.
fn compress_ctb(text: &[u8]) -> Result<Vec<u8>, String> {
use flate2::{write::ZlibEncoder, Compression};
use std::io::Write;
let header = b"PIAFILEVERSION_2.0\r\n";
let mut compressed: Vec<u8> = Vec::new();
{
let mut enc = ZlibEncoder::new(&mut compressed, Compression::default());
enc.write_all(text).map_err(|e| e.to_string())?;
}
let mut out = header.to_vec();
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
let mut body = text.to_vec();
body.push(0);
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(&body).map_err(|e| e.to_string())?;
let compressed = enc.finish().map_err(|e| e.to_string())?;
let mut out = PREFIX.to_vec();
out.extend_from_slice(&adler32(&compressed).to_le_bytes());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
out.extend_from_slice(&compressed);
Ok(out)
}
fn adler32(bytes: &[u8]) -> u32 {
const MOD: u32 = 65_521;
let mut a = 1u32;
let mut b = 0u32;
for byte in bytes {
a = (a + u32::from(*byte)) % MOD;
b = (b + a) % MOD;
}
(b << 16) | a
}
fn packed_rgb([r, g, b]: [u8; 3]) -> i32 {
u32::from_be_bytes([0xC2, r, g, b]) as i32
}
// ── Text parser ───────────────────────────────────────────────────────────────
fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotStyleTable, String> {
if text.lines().any(|line| line.trim() == "begin_plot_style") {
return parse_legacy_plot_style_text(text, name, is_stb);
}
#[derive(Default)]
struct PendingStyle {
index: usize,
name: String,
entry: PlotStyleEntry,
color: Option<i32>,
mode_color: Option<i32>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Section {
Root,
Other,
PlotStyles,
Lineweights,
}
let mut aci_entries: Vec<PlotStyleEntry> =
(0..=255).map(|_| PlotStyleEntry::default()).collect();
let mut named_entries: HashMap<String, PlotStyleEntry> = HashMap::default();
let mut style_index: usize = 1; // CTB: 1-based ACI index
let mut current: Option<PlotStyleEntry> = None;
let mut current_name: String = String::new();
let mut description = String::new();
let mut scale_factor = 1.0f32;
let mut apply_factor = false;
let mut custom_lineweight_display_units = 0u8;
let mut lineweights = Vec::<f32>::new();
let mut section = Section::Root;
let mut current: Option<PendingStyle> = None;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "plot_style{" {
section = Section::PlotStyles;
continue;
}
if line == "custom_lineweight_table{" {
section = Section::Lineweights;
continue;
}
if line.ends_with('{') {
if section == Section::PlotStyles && current.is_none() {
if let Ok(index) = line.trim_end_matches('{').trim().parse::<usize>() {
let default_name = format!("Color_{}", index + 1);
let mut style = PendingStyle {
index,
name: default_name.clone(),
..Default::default()
};
style.entry.name = default_name.clone();
style.entry.localized_name = default_name;
current = Some(style);
continue;
}
}
section = Section::Other;
continue;
}
if line == "}" {
if let Some(mut style) = current.take() {
let packed = style.mode_color.or(style.color);
style.entry.color = packed.and_then(unpack_plot_color);
if is_stb {
named_entries.insert(style.name, style.entry);
} else if style.index < 255 {
aci_entries[style.index + 1] = style.entry;
}
} else {
section = Section::Root;
}
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_start_matches('"');
if let Some(style) = current.as_mut() {
match key {
"name" => {
style.name = value.to_string();
style.entry.name = value.to_string();
}
"localized_name" => style.entry.localized_name = value.to_string(),
"description" => {
style.entry.description = value.to_string();
}
"screen" => {
if let Ok(v) = value.parse::<u8>() {
style.entry.screening = v.min(100);
}
}
"lineweight" => {
if let Ok(v) = value.parse::<u8>() {
style.entry.lineweight = v;
}
}
"color" => style.color = value.parse::<i32>().ok(),
"mode_color" => style.mode_color = value.parse::<i32>().ok(),
"color_policy" => style.entry.color_policy = value.parse().unwrap_or(1),
"physical_pen_number" => {
style.entry.physical_pen_number = value.parse().unwrap_or(0)
}
"virtual_pen_number" => {
style.entry.virtual_pen_number = value.parse().unwrap_or(0)
}
"linepattern_size" => {
style.entry.linepattern_size = value.parse().unwrap_or(0.5)
}
"linetype" => style.entry.linetype = value.parse().unwrap_or(31),
"adaptive_linetype" => {
style.entry.adaptive_linetype = value.eq_ignore_ascii_case("TRUE")
}
"fill_style" => style.entry.fill_style = value.parse().unwrap_or(73),
"end_style" => style.entry.end_style = value.parse().unwrap_or(4),
"join_style" => style.entry.join_style = value.parse().unwrap_or(5),
_ => {}
}
continue;
}
match section {
Section::Root => match key {
"description" => description = value.to_string(),
"scale_factor" => scale_factor = value.parse().unwrap_or(1.0),
"apply_factor" => apply_factor = value.eq_ignore_ascii_case("TRUE"),
"custom_lineweight_display_units" => {
custom_lineweight_display_units = value.parse().unwrap_or(0)
}
_ => {}
},
Section::Lineweights => {
if let (Ok(index), Ok(weight)) = (key.parse::<usize>(), value.parse::<f32>()) {
if lineweights.len() <= index {
lineweights.resize(index + 1, 0.0);
}
lineweights[index] = weight;
}
}
_ => {}
}
}
if lineweights.is_empty() {
lineweights = LW_TABLE.to_vec();
}
Ok(PlotStyleTable {
name,
is_stb,
description,
scale_factor,
apply_factor,
custom_lineweight_display_units,
lineweights,
aci_entries,
named_entries,
})
}
fn unpack_plot_color(packed: i32) -> Option<[u8; 3]> {
if matches!(packed, -1 | -1_006_632_961 | -1_056_964_608) {
return None;
}
let bytes = (packed as u32).to_be_bytes();
Some([bytes[1], bytes[2], bytes[3]])
}
fn parse_legacy_plot_style_text(
text: &str,
name: String,
is_stb: bool,
) -> Result<PlotStyleTable, String> {
let mut table = PlotStyleTable::identity(name);
table.is_stb = is_stb;
let mut style_index = 1usize;
let mut current: Option<PlotStyleEntry> = None;
let mut current_name = String::new();
for line in text.lines().map(str::trim) {
if line == "begin_plot_style" {
current = Some(PlotStyleEntry::default());
current_name = format!("Color_{}", style_index);
current_name = format!("Color_{style_index}");
continue;
}
if line == "end_plot_style" {
if let Some(entry) = current.take() {
if is_stb {
named_entries.insert(current_name.clone(), entry);
table.named_entries.insert(current_name.clone(), entry);
} else if style_index <= 255 {
aci_entries[style_index] = entry;
table.aci_entries[style_index] = entry;
style_index += 1;
}
}
@ -236,54 +636,29 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotS
let Some(entry) = current.as_mut() else {
continue;
};
if let Some((key, val)) = line.split_once('=') {
let key = key.trim();
let val = val.trim();
match key {
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
match key.trim() {
"description" => {
if !val.is_empty() {
entry.description = val.to_string();
current_name = val.to_string();
}
}
"screen" => {
if let Ok(v) = val.parse::<u8>() {
entry.screening = v;
}
}
"lineweight" => {
if let Ok(v) = val.parse::<u8>() {
entry.lineweight = v;
}
}
"color1" => {
if val.starts_with('#') && val.len() == 7 {
// #RRGGBB
let r = u8::from_str_radix(&val[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&val[3..5], 16).unwrap_or(0);
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>() {
// The file packs RGB as a 0xC0RRGGBB negative integer.
// Value 0xC2000000 (-1056964608) = use object color.
if packed != -1056964608i32 {
let u = packed as u32;
let r = ((u >> 16) & 0xFF) as u8;
let g = ((u >> 8) & 0xFF) as u8;
let b = (u & 0xFF) as u8;
entry.color = Some([r, g, b]);
entry.description = value.to_string();
if !value.is_empty() {
current_name = value.to_string();
}
}
"screen" => entry.screening = value.parse::<u8>().unwrap_or(100).min(100),
"lineweight" => entry.lineweight = value.parse().unwrap_or(0),
"color1" if value.starts_with('#') && value.len() == 7 => {
entry.color = Some([
u8::from_str_radix(&value[1..3], 16).unwrap_or(0),
u8::from_str_radix(&value[3..5], 16).unwrap_or(0),
u8::from_str_radix(&value[5..7], 16).unwrap_or(0),
]);
}
"color1" => entry.color = value.parse::<i32>().ok().and_then(unpack_plot_color),
_ => {}
}
}
}
Ok(PlotStyleTable {
name,
is_stb,
aci_entries,
named_entries,
})
Ok(table)
}

View file

@ -15,7 +15,8 @@ use crate::scene::WireModel;
/// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp`
/// flags / `-o` options. On Windows the generated PDF already carries render
/// options, while copies and driver quality are not exposed by the shell verb.
/// options. Windows queues repeated jobs when more than one copy is requested;
/// driver quality remains managed by the selected printer.
#[derive(Debug, Clone, Default)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub struct PrintOptions {
@ -23,12 +24,8 @@ pub struct PrintOptions {
pub printer: Option<String>,
/// Number of copies (treated as at least 1).
pub copies: u32,
/// Force grayscale output.
pub mono: bool,
/// Print quality label selected in the plot dialog.
pub quality: Option<String>,
/// Rasterisation resolution in DPI.
pub dpi: Option<u32>,
/// Controls applied while building the intermediate PDF.
pub render: crate::io::pdf_export::PdfPlotOptions,
}
@ -215,8 +212,7 @@ fn dispatch_to_printer_opts(
#[cfg(target_os = "windows")]
{
// Target a named printer via the "printto" verb; fall back to the
// default-printer "print" verb. Copies / quality / colour aren't
// expressible through a shell verb, so they are ignored here.
// default-printer "print" verb.
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let wide = |s: &str| -> Vec<u16> { OsStr::new(s).encode_wide().chain(Some(0)).collect() };
@ -226,6 +222,7 @@ fn dispatch_to_printer_opts(
_ => (wide("print"), None, "default printer".to_string()),
};
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
for _ in 0..opts.copies.max(1) {
let result = unsafe {
windows_sys::Win32::UI::Shell::ShellExecuteW(
std::ptr::null_mut(),
@ -236,12 +233,12 @@ fn dispatch_to_printer_opts(
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
) as usize
};
if result > 32 {
Ok(label)
} else {
Err(format!("ShellExecute failed (code {result})"))
if result <= 32 {
return Err(format!("ShellExecute failed (code {result})"));
}
}
Ok(label)
}
#[cfg(not(target_os = "windows"))]
{
@ -256,17 +253,11 @@ fn dispatch_to_printer_opts(
if copies > 1 {
cmd.arg("-n").arg(copies.to_string());
}
if opts.mono {
cmd.arg("-o").arg("ColorModel=Gray");
}
if let Some(dpi) = opts.dpi {
cmd.arg("-o").arg(format!("Resolution={dpi}dpi"));
}
if let Some(q) = opts.quality.as_deref() {
// CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best.
let pq = match q {
"Draft" => "3",
"Presentation" | "Maximum" => "5",
"Low" => "3",
"High" => "5",
_ => "4",
};
cmd.arg("-o").arg(format!("print-quality={pq}"));

View file

@ -4825,17 +4825,49 @@ impl Scene {
}
/// 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>) {
/// A plot-only render override is applied to cloned wires; viewport entities
/// and their saved display modes remain unchanged.
pub fn plot_wire_groups(
&self,
render_mode_override: Option<acadrust::entities::ViewportRenderMode>,
) -> (Vec<WireModel>, Vec<WireModel>) {
let apply_mode = |wires: &mut Vec<WireModel>, mode| {
let flags = view::render::render_mode_flags(mode);
for wire in wires.iter_mut().filter(|wire| wire.fill_is_3d) {
if !flags.face3d_fill && !flags.mesh_fill {
wire.fill_tris.clear();
wire.fill_tris_low.clear();
}
if !flags.show_3d_edges {
wire.points.clear();
wire.points_low.clear();
}
}
};
if self.current_layout == "Model" {
return (self.entity_wires_arc().as_ref().clone(), Vec::new());
let mut wires = self.entity_wires_arc().as_ref().clone();
apply_mode(
&mut wires,
render_mode_override.unwrap_or_else(|| self.active_model_tile_render_mode()),
);
return (wires, 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),
)
let (_, _, viewport_handles) = self.paper_viewport_handles();
let mut model_wires = Vec::new();
for handle in viewport_handles.iter().copied() {
let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else {
continue;
};
if viewport.common.owner_handle != paper_block || !viewport.status.is_on {
continue;
}
let mode = render_mode_override.unwrap_or(viewport.render_mode);
let mut wires = self.viewport_content_wires(paper_block, Some(handle), None);
apply_mode(&mut wires, mode);
model_wires.extend(wires);
}
(self.paper_sheet_wires_arc().as_ref().clone(), model_wires)
}
/// Per-entity stable draw-order depth, keyed by entity handle value.

View file

@ -20,21 +20,22 @@ pub const OUT_PDF: &str = "Save to PDF file…";
/// settings captured when the dialog opened.
pub const SETUP_NONE: &str = "<none>";
pub const SETUP_PREV: &str = "<previous>";
pub const STYLE_NONE: &str = "<none>";
/// One of the many boolean plot options (folded into a single message so the
/// dialog needn't carry a variant per checkbox).
#[derive(Debug, Clone, Copy)]
pub enum PlotFlag {
Background,
MergeLines,
FitToPaper,
Center,
ScaleLw,
UpsideDown,
Mono,
Lineweights,
WithStyles,
Transparency,
PaperspaceLast,
Stamp,
SaveLayout,
}
/// Every edit the Plot dialog can emit. Wrapped in `Message::PlotDlg` so the
@ -55,10 +56,10 @@ pub enum PlotDlgMsg {
Copies(String),
OffsetX(String),
OffsetY(String),
Dpi(String),
Flag(PlotFlag),
LoadStyle,
ClearStyle,
SaveStyle,
Style(String),
PickWindow,
// ── Named page-setup manager ─────────────────────────────────────────
/// Pick a named page setup (loads its values into the editor).
@ -112,19 +113,24 @@ pub struct PlotDialogState {
#[serde(skip)]
pub offset_y: String,
pub scale: String,
#[serde(default = "legacy_fit_to_paper_default")]
pub fit_to_paper: bool,
#[serde(skip)]
pub scales: Vec<(String, f64)>,
pub scale_lw: bool,
pub quality: String,
pub dpi: String,
pub shade: String,
pub mono: bool,
pub background: bool,
pub merge_lines: bool,
pub lineweights: bool,
pub with_styles: bool,
pub transparency: bool,
pub paperspace_last: bool,
pub stamp: bool,
pub save_layout: bool,
/// Display name of the active plot style table ("" = none).
pub style_name: String,
/// CTB file names discovered in the per-user plot styles folder.
#[serde(skip)]
pub plot_styles: Vec<String>,
/// The selected setup references a style table that is not loaded.
#[serde(skip)]
pub style_missing: bool,
@ -159,19 +165,20 @@ impl Default for PlotDialogState {
center: true,
offset_x: "0.0".into(),
offset_y: "0.0".into(),
scale: "Fit".into(),
scale: "1:1".into(),
fit_to_paper: true,
scales: Vec::new(),
scale_lw: true,
quality: "Normal".into(),
dpi: "300".into(),
shade: "As displayed".into(),
mono: false,
background: true,
merge_lines: false,
lineweights: true,
with_styles: true,
transparency: false,
paperspace_last: false,
stamp: false,
save_layout: false,
style_name: String::new(),
plot_styles: Vec::new(),
style_missing: false,
page_setups: Vec::new(),
selected_setup: String::new(),
@ -198,23 +205,26 @@ impl PlotDialogState {
self.offset_x = o.offset_x.clone();
self.offset_y = o.offset_y.clone();
self.scale = o.scale.clone();
self.fit_to_paper = o.fit_to_paper;
self.scale_lw = o.scale_lw;
self.quality = o.quality.clone();
self.dpi = o.dpi.clone();
self.shade = o.shade.clone();
self.mono = o.mono;
self.background = o.background;
self.merge_lines = o.merge_lines;
self.lineweights = o.lineweights;
self.with_styles = o.with_styles;
self.transparency = o.transparency;
self.paperspace_last = o.paperspace_last;
self.stamp = o.stamp;
self.save_layout = o.save_layout;
self.style_name = o.style_name.clone();
self.style_missing = o.style_missing;
}
}
fn legacy_fit_to_paper_default() -> bool {
false
}
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, st| {
let palette = theme.palette();
@ -436,15 +446,6 @@ fn check_enabled<'a>(
.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)
.width(Length::Fill)
@ -545,29 +546,27 @@ pub fn view_window(
left: 12.0,
});
let mut left_bar = row![
let mut copy_button = button(text("Copy").size(11))
.style(btn(false))
.padding([4, 12]);
if can_copy {
copy_button = copy_button.on_press(Message::PlotDlg(PlotDlgMsg::CopySetup));
}
let mut delete_button = button(text("Delete").size(11))
.style(btn(false))
.padding([4, 12]);
if is_named {
delete_button = delete_button.on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup));
}
let left_bar = row![
button(text("New").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::NewSetup))
.style(btn(false))
.padding([4, 12]),
copy_button,
delete_button,
]
.spacing(4);
if can_copy {
left_bar = left_bar.push(
button(text("Copy").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::CopySetup))
.style(btn(false))
.padding([4, 12]),
);
}
if is_named {
left_bar = left_bar.push(
button(text("Delete").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup))
.style(btn(false))
.padding([4, 12]),
);
}
// ── Printer / plotter ─────────────────────────────────────────────────
let mut printer_opts = vec![OUT_DEFAULT.to_string()];
@ -580,7 +579,7 @@ pub fn view_window(
};
let paper_opts: Vec<String> = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect();
let paper_note: Element<'_, Message> = if s.area == "Layout" {
text("Layout plots the current sheet; Apply to layout updates its paper size.")
text("Layout plots the current sheet using the selected paper size.")
.size(10)
.style(muted_style)
.width(width)
@ -606,19 +605,16 @@ pub fn view_window(
.padding([4, 8]),
);
}
let destination = if s.to_file {
"Destination: PDF file"
} else if s.printer.is_some() {
"Destination: selected system printer"
let copies_row: Element<'_, Message> = if s.to_file {
Space::new().height(0).into()
} else {
"Destination: system default printer"
field_row("Copies", &s.copies, PlotDlgMsg::Copies, 60)
};
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),
copies_row,
]
.spacing(7),
);
@ -664,51 +660,57 @@ pub fn view_window(
.spacing(7),
check_enabled("Center the plot", s.center, PlotFlag::Center, common_area),
].spacing(7));
let scale_options = s.scales.iter().map(|(name, _)| name.clone()).collect();
let scale_panel = panel(column![
section_label("Plot scale"),
check_enabled(
"Fit to paper",
s.fit_to_paper,
PlotFlag::FitToPaper,
common_area,
),
drop_row_enabled(
"Scale",
strs(&["Fit", "1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "2:1"]),
Some(if common_area { s.scale.clone() } else { "1:1".into() }),
scale_options,
Some(s.scale.clone()),
PlotDlgMsg::Scale,
width,
common_area,
common_area && !s.fit_to_paper,
),
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)
let mut style_options = vec![STYLE_NONE.to_string()];
style_options.extend(s.plot_styles.iter().cloned());
if !s.style_name.is_empty()
&& !style_options
.iter()
.any(|name| name.eq_ignore_ascii_case(&s.style_name))
{
style_options.push(s.style_name.clone());
}
let style_selected = if s.style_name.is_empty() {
STYLE_NONE.to_string()
} else {
s.style_name.clone()
};
let style_panel = panel(column![
section_label("Plot style table (pen assignments)"),
drop_row(
"Table",
style_options,
Some(style_selected),
PlotDlgMsg::Style,
width,
),
row![
container(text(style_label).size(12))
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
})
.padding([4, 8])
.width(width),
button(text("Load…").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::LoadStyle))
.style(btn(false))
.padding([4, 10]),
button(text("Clear").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::ClearStyle))
button(text("Save…").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::SaveStyle))
.style(btn(false))
.padding([4, 10]),
]
@ -720,62 +722,53 @@ pub fn view_window(
section_label("Shaded viewport options"),
drop_row(
"Shade plot",
strs(&["As displayed", "Wireframe"]),
strs(&[
"As displayed",
"2D Wireframe",
"3D Wireframe",
"Hidden Line",
"Flat Shaded",
"Gouraud Shaded",
"Flat Shaded + Edges",
"Gouraud Shaded + Edges",
]),
Some(s.shade.clone()),
PlotDlgMsg::Shade,
width,
),
drop_row(
"Quality",
strs(&["Draft", "Preview", "Normal", "Presentation", "Maximum", "Custom"]),
strs(&["Low", "Normal", "High"]),
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 paper_order_option: Element<'_, Message> = if s.paper_space {
check(
"Paper space last",
s.paperspace_last,
PlotFlag::PaperspaceLast,
)
} else {
Space::new().height(0).into()
};
let options_panel = panel(column![
section_label("Plot options"),
row![
column![
check_static("Plot in background", true),
check("Plot in background", s.background, PlotFlag::Background),
check("Object lineweights", s.lineweights, PlotFlag::Lineweights),
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_enabled(
"Paper space last",
s.paperspace_last,
PlotFlag::PaperspaceLast,
s.paper_space,
),
check_static("Hide paper objects (unavailable)", false),
paper_order_option,
check("Merge overlapping lines", s.merge_lines, PlotFlag::MergeLines),
check("Plot stamp", s.stamp, PlotFlag::Stamp),
check_enabled(
"Save changes to layout",
s.save_layout,
PlotFlag::SaveLayout,
s.paper_space,
),
]
.spacing(6)
.width(width),
@ -784,7 +777,6 @@ pub fn view_window(
].spacing(7));
let orientation_panel = panel(column![
section_label("Drawing orientation"),
drop_row(
"Orientation",
strs(&["Portrait", "Landscape"]),