feat(plot): page-setup units, custom scale, hide-paperspace, save-to-layout and the plot sysvars

Plot dialog:
- "Units" (millimetres / inches) is the page setup's own unit: the offsets
  and the custom scale are shown in it, the stored ratio converts with it
  so the plotted result and the paper space's unit stay the same
- "Custom: [paper] mm = [drawing] units" spells the picked scale in that
  unit and accepts a typed ratio, which selects the list's name for it or
  becomes a ratio entry of its own
- "Hide paperspace objects" is the page setup's plot-hidden flag; it is no
  longer derived from the "Hidden Line" shade mode
- "Save changes to layout" (paper layouts, on by default) writes the dialog
  into the layout when a plot is sent; previews never touch it
- offsets are labelled in the page-setup unit and kept in millimetres in
  the file

System variables (typeable, through SETVAR, listed by SETVAR ?):
- PLOTOFFSET, PAPERUPDATE, PLOTROTMODE, PLOTTRANSPARENCYOVERRIDE and
  BACKGROUNDPLOT live with the persisted plot preferences: the plot origin
  counts from the printable corner unless PLOTOFFSET says the paper edge;
  a printer that does not list the sheet either warns or switches to its
  default sheet; transparency can be forced on or off; BACKGROUNDPLOT bit 1
  is the dialog's background switch and bit 2 drives batch plots
- CTAB and TILEMODE read and switch the current layout

Tests cover every new control and variable; six new strings in all 21
locales; the Plot dialog grows to 940×690 for the two extra rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-18 14:53:01 +03:00
1 parent 2b38f798c0
commit 7efb04a666
29 files changed
+896 -69

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
//! Plot system variables. `PLOTOFFSET`, `PAPERUPDATE`, `PLOTROTMODE`,
//! `PLOTTRANSPARENCYOVERRIDE` and `BACKGROUNDPLOT` are plot preferences of
//! the application, kept with the Plot dialog's persisted settings; `CTAB`
//! and `TILEMODE` say which layout of the drawing is current. Each is
//! typeable on its own (`PLOTOFFSET 1`, `CTAB Layout1`) and through
//! `SETVAR`, and a bare name prompts for the value like the other variables.
use crate::app::{Message, OpenCADStudio};
use iced::Task;
/// The variables this family answers for, for the command registry and the
/// `SETVAR ?` listing.
pub(super) const PLOT_SYSVARS: &[&str] = &[
"PLOTOFFSET",
"PAPERUPDATE",
"PLOTROTMODE",
"PLOTTRANSPARENCYOVERRIDE",
"BACKGROUNDPLOT",
"CTAB",
"TILEMODE",
];
/// The `SETVAR ?` line for this family.
pub(super) fn setvar_listing() -> String {
format!("SETVAR (plot): {}", PLOT_SYSVARS.join(" "))
}
/// What setting a variable did.
enum Outcome {
/// The value was set (or was already that); the variable is part of the
/// application's settings and persists with them.
Preference(String),
/// The value was set and the drawing changed accordingly, possibly with
/// a follow-up task (a layout switch).
Drawing(String, Task<Message>),
/// No value given: the current one, for the prompt.
Current(String),
}
impl OpenCADStudio {
pub(super) fn dispatch_plotvars(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd);
let mut parts = rest.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("").to_ascii_uppercase();
if !PLOT_SYSVARS.contains(&name.as_str()) {
return None;
}
let value = parts.next().map(str::trim).filter(|v| !v.is_empty());
let task = match self.plot_sysvar(i, &name, value) {
Ok(Outcome::Preference(shown)) => {
self.persist_settings_if_changed();
self.command_line.push_output(&format!("{name} = {shown}"));
Task::none()
}
Ok(Outcome::Drawing(shown, task)) => {
self.command_line.push_output(&format!("{name} = {shown}"));
task
}
Ok(Outcome::Current(current)) => {
self.command_line.push_output(
crate::tf!("Enter new value for {name} <{current}>:").as_ref(),
);
self.pending_setvar = Some(name);
Task::none()
}
Err(error) => {
self.command_line.push_error(&error);
Task::none()
}
};
Some(task)
}
fn plot_sysvar(
&mut self,
i: usize,
name: &str,
value: Option<&str>,
) -> Result<Outcome, String> {
let parse_range = |value: &str, max: u8| -> Result<u8, String> {
value
.parse::<u8>()
.ok()
.filter(|v| *v <= max)
.ok_or_else(|| format!("SETVAR: integer from 0 to {max} required."))
};
let d = &mut self.plot_dialog;
match name {
"PLOTOFFSET" => Ok(match value {
Some(v) => {
d.plot_offset_from_edge = parse_range(v, 1)? == 1;
Outcome::Preference((d.plot_offset_from_edge as u8).to_string())
}
None => Outcome::Current((d.plot_offset_from_edge as u8).to_string()),
}),
"PAPERUPDATE" => Ok(match value {
Some(v) => {
d.paper_update = parse_range(v, 1)?;
Outcome::Preference(d.paper_update.to_string())
}
None => Outcome::Current(d.paper_update.to_string()),
}),
"PLOTROTMODE" => Ok(match value {
Some(v) => {
d.plot_rot_mode = parse_range(v, 2)?;
Outcome::Preference(d.plot_rot_mode.to_string())
}
None => Outcome::Current(d.plot_rot_mode.to_string()),
}),
"PLOTTRANSPARENCYOVERRIDE" => Ok(match value {
Some(v) => {
d.transparency_override = parse_range(v, 2)?;
Outcome::Preference(d.transparency_override.to_string())
}
None => Outcome::Current(d.transparency_override.to_string()),
}),
// Bit 1: single plots run in the background; bit 2: batch plots.
"BACKGROUNDPLOT" => {
let current = d.background as u8 | (d.background_publish as u8) << 1;
Ok(match value {
Some(v) => {
let bits = parse_range(v, 3)?;
d.background = bits & 1 != 0;
d.background_publish = bits & 2 != 0;
Outcome::Preference(bits.to_string())
}
None => Outcome::Current(current.to_string()),
})
}
"CTAB" => {
let current = self.tabs[i].scene.current_layout.clone();
let Some(v) = value else {
return Ok(Outcome::Current(current));
};
let Some(target) = self.tabs[i]
.scene
.layout_names()
.into_iter()
.find(|layout| layout.eq_ignore_ascii_case(v))
else {
return Err(format!("CTAB: no layout named \"{v}\"."));
};
let task = if target == current {
Task::none()
} else {
self.on_layout_switch(target.clone())
};
Ok(Outcome::Drawing(target, task))
}
// 1: model space; 0: the paper layout that was last current (the
// drawing's own `CTAB`), else the first one.
"TILEMODE" => {
let in_model = self.tabs[i].scene.current_layout == "Model";
let Some(v) = value else {
return Ok(Outcome::Current((in_model as u8).to_string()));
};
let model = parse_range(v, 1)? == 1;
if model == in_model {
return Ok(Outcome::Drawing((model as u8).to_string(), Task::none()));
}
let target = if model {
"Model".to_string()
} else {
let scene = &self.tabs[i].scene;
let layouts = scene.layout_names();
crate::io::saved_active_layout(&scene.document)
.filter(|saved| saved != "Model" && layouts.contains(saved))
.or_else(|| layouts.into_iter().find(|layout| layout != "Model"))
.ok_or_else(|| "TILEMODE: the drawing has no paper layout.".to_string())?
};
let task = self.on_layout_switch(target);
Ok(Outcome::Drawing((model as u8).to_string(), task))
}
_ => Err(format!("SETVAR: unknown variable \"{name}\".")),
}
}
}
#[cfg(test)]
mod tests {
use crate::app::OpenCADStudio;
fn app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
fn last_line(app: &OpenCADStudio) -> String {
app.command_line.history.last().map(|line| line.text.clone()).unwrap_or_default()
}
#[test]
fn plot_preferences_are_typeable_directly_and_through_setvar() {
let mut app = app();
let _ = app.run_command_line("PLOTOFFSET 1");
assert!(app.plot_dialog.plot_offset_from_edge);
assert_eq!(last_line(&app), "PLOTOFFSET = 1");
let _ = app.run_command_line("SETVAR PAPERUPDATE 1");
assert_eq!(app.plot_dialog.paper_update, 1);
let _ = app.run_command_line("PLOTROTMODE 0");
assert_eq!(app.plot_dialog.plot_rot_mode, 0);
let _ = app.run_command_line("PLOTTRANSPARENCYOVERRIDE 2");
assert_eq!(app.plot_dialog.transparency_override, 2);
// Out-of-range values are refused and leave the setting alone.
let _ = app.run_command_line("PLOTROTMODE 3");
assert_eq!(app.plot_dialog.plot_rot_mode, 0);
assert!(last_line(&app).contains("0 to 2"));
// The preferences reach the persisted plot config.
let config = app.current_config();
assert!(config.plot.plot_offset_from_edge);
assert_eq!(config.plot.transparency_override, 2);
}
#[test]
fn backgroundplot_maps_to_the_two_background_switches() {
let mut app = app();
let _ = app.run_command_line("BACKGROUNDPLOT 2");
assert!(!app.plot_dialog.background);
assert!(app.plot_dialog.background_publish);
let _ = app.run_command_line("BACKGROUNDPLOT 3");
assert!(app.plot_dialog.background && app.plot_dialog.background_publish);
let _ = app.run_command_line("BACKGROUNDPLOT");
assert_eq!(app.pending_setvar.as_deref(), Some("BACKGROUNDPLOT"));
assert!(last_line(&app).contains("<3>"));
}
#[test]
fn ctab_and_tilemode_switch_the_current_layout() {
let mut app = app();
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Model");
let _ = app.run_command_line("CTAB layout1");
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1");
assert_eq!(last_line(&app), "CTAB = Layout1");
let _ = app.run_command_line("TILEMODE 1");
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Model");
// Back to paper space: the layout that was current last.
let _ = app.run_command_line("SETVAR TILEMODE 0");
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1");
let _ = app.run_command_line("CTAB Nowhere");
assert!(last_line(&app).contains("no layout named"));
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1");
let _ = app.run_command_line("CTAB");
assert!(last_line(&app).contains("<Layout1>"));
assert_eq!(app.pending_setvar.as_deref(), Some("CTAB"));
}
}