Files
OneCAD-Draft/src/app/update/file.rs
T
2026-10-02 17:52:13 +03:00

6717 lines
291 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
#![allow(unused_imports)]
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::model::object::GripApply;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::PropertiesPanel;
use codec::types::Color as AcadColor;
use codec::{EntityType as AcadEntityType, Handle};
use iced::time::Instant;
use iced::{mouse, Point, Task};
/// Write an exported file: to disk on the desktop, as a browser download on
/// the web, where there is no filesystem behind the dialog's path. (#761)
fn write_export(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
#[cfg(target_arch = "wasm32")]
{
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy());
crate::sys::download_bytes(&name, bytes);
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::write(path, bytes).map_err(|e| e.to_string())
}
}
/// Run `work` on a worker thread and map its result into a `Message`.
///
/// `context` names the operation for error reporting. A worker panic never
/// reaches the UI thread: it is caught on the worker and re-surfaced as
/// [`Message::BackgroundTaskFailed`] so a crashing export or import reports
/// on the command line instead of killing the application.
pub(super) fn background_task<T, F, M>(context: impl Into<String>, work: F, map: M) -> Task<Message>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
M: FnOnce(T) -> Message + Send + 'static,
{
#[cfg(not(target_arch = "wasm32"))]
{
let context = context.into();
let (tx, rx) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
let _ = tx.send(result);
});
Task::perform(
async move {
match rx.await {
Ok(Ok(value)) => map(value),
Ok(Err(payload)) => Message::BackgroundTaskFailed {
context,
detail: panic_detail(payload),
},
Err(_) => Message::BackgroundTaskFailed {
context,
detail: "worker stopped without reporting a result".to_string(),
},
}
},
|message| message,
)
}
#[cfg(target_arch = "wasm32")]
{
let _ = context;
Task::perform(async move { work() }, map)
}
}
/// Render a panic payload the way `std` reports it: `&'static str` messages,
/// `String` messages, or a fallback for foreign payloads.
#[cfg(not(target_arch = "wasm32"))]
fn panic_detail(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(text) = payload.downcast_ref::<&'static str>() {
(*text).to_string()
} else if let Some(text) = payload.downcast_ref::<String>() {
text.clone()
} else {
"worker panicked".to_string()
}
}
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<codec::entities::ViewportRenderMode> {
use codec::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 (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
use crate::io::pdf_export::{PdfPageInput, PlotContent};
/// The dialog's sheet orientation as a catalogue value.
fn plot_dialog_orientation(
d: &crate::ui::window::plot::PlotDialogState,
) -> crate::io::paper_catalog::Orientation {
use crate::io::paper_catalog::Orientation;
if d.orientation == "Portrait" {
Orientation::Portrait
} else {
Orientation::Landscape
}
}
/// The sheet the dialog names, or — when the name carries no size (an old
/// config, a bare driver name) — a custom sheet built from the dialog's stored
/// dimensions, so a drawing whose paper we cannot identify still plots at its
/// own size.
fn plot_dialog_paper(
d: &crate::ui::window::plot::PlotDialogState,
) -> crate::io::paper_catalog::PaperSize {
use crate::io::paper_catalog::{from_drawing, resolve, PaperSize, PaperUnits};
if d.paper_width_mm > 0.0 && d.paper_height_mm > 0.0 {
from_drawing(&d.paper, d.paper_width_mm, d.paper_height_mm)
} else {
resolve(&d.paper)
.unwrap_or_else(|| PaperSize::custom(210.0, 297.0, PaperUnits::Millimeters))
}
}
fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) {
plot_dialog_paper(d).sheet_mm(plot_dialog_orientation(d))
}
/// Whether the dialog still names the sheet the stored settings carry.
fn paper_unchanged(
d: &crate::ui::window::plot::PlotDialogState,
stored: &codec::objects::PlotSettings,
) -> bool {
!stored.paper_size.is_empty()
&& crate::io::paper_catalog::from_drawing(
&stored.paper_size,
stored.paper_width,
stored.paper_height,
)
.canonical
== d.paper
}
/// The media name to store for the dialog's sheet. A drawing keeps its own
/// spelling of a sheet the user did not change — a system printer's `A4` or a
/// plotter's custom name must not turn into the catalogue's canonical name
/// just by passing through the dialog, or the source application would look the sheet up
/// under a name that driver never reported. Only a genuinely different
/// selection writes the canonical name.
fn paper_name_for_settings(
d: &crate::ui::window::plot::PlotDialogState,
stored: &codec::objects::PlotSettings,
) -> String {
if paper_unchanged(d, stored) {
stored.paper_size.clone()
} else {
d.paper.clone()
}
}
/// The stored rotation for the dialog's sheet on a medium that is itself
/// `natural`-oriented: a quarter turn when the requested orientation differs
/// from the medium's own, and "upside-down" adds a half turn on top. This is
/// how the commercial application writes it — a system printer's portrait
/// `A3` plotted landscape carries 90°, while its PDF driver's landscape
/// `ISO_A4_(297.00_x_210.00_MM)` medium plotted landscape carries 0°.
fn plot_dialog_rotation(
d: &crate::ui::window::plot::PlotDialogState,
natural: crate::io::paper_catalog::Orientation,
) -> codec::objects::PlotRotation {
use codec::objects::PlotRotation;
match (plot_dialog_orientation(d) != natural, d.upside_down) {
(false, false) => PlotRotation::None,
(true, false) => PlotRotation::Degrees90,
(false, true) => PlotRotation::Degrees180,
(true, true) => PlotRotation::Degrees270,
}
}
/// Millimetres in the paper unit a page setup counts in.
fn plot_paper_unit_mm(units: codec::objects::PlotPaperUnits) -> f64 {
match units {
codec::objects::PlotPaperUnits::Inches => 25.4,
_ => 1.0,
}
}
/// The factor the commercial application stores next to the scale (DXF 147)
/// is the `paper : drawing` ratio times 25.4 on millimetre page setups and
/// the plain ratio on inch ones — observed in its files, whatever the
/// published description says. Both directions of that convention live here.
fn stored_scale_factor(ratio: f64, units: codec::objects::PlotPaperUnits) -> f64 {
match units {
codec::objects::PlotPaperUnits::Millimeters => ratio * 25.4,
_ => ratio,
}
}
fn ratio_from_stored_scale_factor(factor: f64, units: codec::objects::PlotPaperUnits) -> f64 {
match units {
codec::objects::PlotPaperUnits::Millimeters => factor / 25.4,
_ => factor,
}
}
/// The file format's standard scale for a `paper : drawing` ratio, when it
/// has one. Only the metric ratios exist as standard codes here; an
/// architectural scale is stored as a custom ratio with the same value.
fn standard_scale_for_ratio(paper: f64, drawing: f64) -> Option<codec::objects::ScaledType> {
use codec::objects::ScaledType as S;
const STANDARD: &[(f64, f64, S)] = &[
(1.0, 1.0, S::OneToOne),
(1.0, 2.0, S::OneToTwo),
(1.0, 4.0, S::OneToFour),
(1.0, 8.0, S::OneToEight),
(1.0, 10.0, S::OneToTen),
(1.0, 16.0, S::OneToSixteen),
(1.0, 20.0, S::OneToTwenty),
(1.0, 30.0, S::OneToThirty),
(1.0, 40.0, S::OneToForty),
(1.0, 50.0, S::OneToFifty),
(1.0, 100.0, S::OneToHundred),
(2.0, 1.0, S::TwoToOne),
(4.0, 1.0, S::FourToOne),
(8.0, 1.0, S::EightToOne),
(10.0, 1.0, S::TenToOne),
(100.0, 1.0, S::HundredToOne),
];
let ratio = paper / drawing;
STANDARD
.iter()
.find(|(p, d, _)| (p / d - ratio).abs() <= 1e-9 * ratio.abs().max(1.0))
.map(|(_, _, scale)| *scale)
}
/// `12.5000` → `12.5`, `3.0000` → `3`: a fixed-precision number without its
/// trailing zeros, for fields the user reads back.
fn trim_decimals(text: &str) -> String {
if text.contains('.') {
text.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
text.to_string()
}
}
/// A scale-list name for a plot scale the drawing has no name for, spelled as
/// the ratio it is (`1:250`, `25.4:1`) so the value survives in the picker
/// instead of collapsing to 1:1.
fn ratio_scale_name(factor: f64) -> String {
if factor >= 1.0 {
format!("{}:1", trim_decimals(&format!("{factor:.4}")))
} else {
format!("1:{}", trim_decimals(&format!("{:.4}", 1.0 / factor)))
}
}
/// The plot device the dialog currently targets.
fn plot_dialog_device(d: &crate::ui::window::plot::PlotDialogState) -> crate::io::plot_device::PlotDevice {
use crate::io::plot_device::PlotDevice;
if d.to_file {
PlotDevice::Pdf
} else {
match &d.printer {
Some(name) => PlotDevice::Printer(name.clone()),
None => PlotDevice::None,
}
}
}
/// The plotter name to store. Same rule as the sheet: a device name the source application
/// wrote (its PDF driver, a plotter `.pc3` we plot to PDF for it) stays as it
/// is while the dialog still points at the same kind of device; a changed
/// device, or one of our own legacy labels, is written in the source application's spelling.
fn device_name_for_settings(
d: &crate::ui::window::plot::PlotDialogState,
stored: &codec::objects::PlotSettings,
) -> String {
use crate::io::plot_device::PlotDevice;
let device = plot_dialog_device(d);
let (stored_device, keep_stored) = PlotDevice::from_stored_name(&stored.printer_name);
if keep_stored && stored_device == device {
stored.printer_name.clone()
} else {
device.canonical_name()
}
}
/// The unprintable margins to store: the drawing's own while neither the
/// sheet nor the device changed (they came from the source application's driver and we have
/// no better source), otherwise what our device reports for the new sheet —
/// the equivalent of the source application's `PAPERUPDATE`.
fn margins_for_settings(
d: &crate::ui::window::plot::PlotDialogState,
stored: &codec::objects::PlotSettings,
) -> codec::objects::PaperMargin {
use crate::io::plot_device::PlotDevice;
let device = plot_dialog_device(d);
let (stored_device, _) = PlotDevice::from_stored_name(&stored.printer_name);
if paper_unchanged(d, stored) && stored_device == device {
return stored.margins;
}
let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers);
codec::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top)
}
fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> {
let PlotContent { wires, hatches, wipeouts, images, .. } = content;
let mut bounds = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
let mut include = |x: f64, y: f64| {
if x.is_finite() && y.is_finite() {
bounds.0 = bounds.0.min(x);
bounds.1 = bounds.1.min(y);
bounds.2 = bounds.2.max(x);
bounds.3 = bounds.3.max(y);
}
};
for wire in wires.iter().filter(|wire| wire.name != "paper_printable_area") {
let [x0, y0, x1, y1] = wire.aabb;
include(x0 as f64, y0 as f64);
include(x1 as f64, y1 as f64);
}
for hatch in wipeouts.iter().chain(hatches.iter()) {
for &[x, y] in hatch.boundary.iter() {
include(
hatch.world_origin[0] + x as f64,
hatch.world_origin[1] + y as f64,
);
}
}
for plot in images {
let mut image_bounds = [f64::INFINITY, f64::INFINITY,
f64::NEG_INFINITY, f64::NEG_INFINITY];
for vertex in &plot.image.verts {
for axis in 0..2 {
let value = vertex.pos[axis] as f64 + vertex.pos_low[axis] as f64;
image_bounds[axis] = image_bounds[axis].min(value);
image_bounds[axis + 2] = image_bounds[axis + 2].max(value);
}
}
for clip in &plot.clips {
for axis in 0..2 {
let min = clip.iter().map(|p| p[axis]).fold(f64::INFINITY, f64::min);
let max = clip.iter().map(|p| p[axis]).fold(f64::NEG_INFINITY, f64::max);
image_bounds[axis] = image_bounds[axis].max(min);
image_bounds[axis + 2] = image_bounds[axis + 2].min(max);
}
}
if image_bounds[0] < image_bounds[2] && image_bounds[1] < image_bounds[3] {
include(image_bounds[0], image_bounds[1]);
include(image_bounds[2], image_bounds[3]);
}
}
(bounds.0.is_finite()
&& bounds.1.is_finite()
&& bounds.2.is_finite()
&& bounds.3.is_finite()
&& bounds.2 > bounds.0
&& bounds.3 > bounds.1)
.then_some(bounds)
}
/// Stamp the owning entity's effective color index onto a plot wire so CTB
/// plot-style lookups (`wire.aci > 0`) apply: an entity's own ACI 1-255 is
/// used as-is, ByLayer resolves through the layer table, and true-color
/// objects keep `aci = 0` ("0 means true-color" — no CTB mapping). Wires
/// whose owner cannot be resolved keep the index they carry.
fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireModel) {
let Some(handle) = crate::scene::Scene::handle_from_wire_name(&wire.name) else {
return;
};
let Some(entity) = scene.document.get_entity(handle) else {
return;
};
let common = entity.common();
wire.aci = match &common.color {
codec::types::Color::Index(index) => *index,
codec::types::Color::ByLayer => scene
.document
.layers
.get(&common.layer)
.map(|layer| match &layer.color {
codec::types::Color::Index(index) => *index,
_ => 0,
})
.unwrap_or(7),
_ => 0,
};
}
fn plot_scene_content(
scene: &crate::scene::Scene,
paper_space_last: bool,
render_mode_override: Option<codec::entities::ViewportRenderMode>,
) -> PlotContent {
let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override);
let plot_viewport_borders = scene
.effective_plot_settings()
.is_none_or(|settings| settings.flags.plot_viewport_borders);
paper_wires.retain(|wire| {
wire.plot_visible
&& (plot_viewport_borders
|| !crate::scene::Scene::handle_from_wire_name(&wire.name)
.and_then(|handle| scene.document.get_entity(handle))
.is_some_and(|entity| {
matches!(
entity,
codec::EntityType::Viewport(viewport)
if !crate::scene::Scene::is_sheet_viewport(
&scene.document,
viewport,
)
)
}))
});
model_wires.retain(|wire| wire.plot_visible);
// A text / leader background mask is filled with the canvas colour so it
// hides what is behind it; on paper that colour is the sheet's, not the
// dark screen's. Mark such fills so the export paints them paper-white
// instead of plotting the canvas colour (or turning it black). (#1072)
let canvas = scene.current_bg();
let with_depth = |wires: Vec<crate::scene::WireModel>| {
let depths = scene.plot_wire_depths(&wires);
wires
.into_iter()
.zip(depths)
.map(|(mut wire, draw_depth)| {
if !wire.fill_tris.is_empty() && wire.color[..3] == canvas[..3] {
wire.bg_adapt = Some(Box::new(crate::scene::model::wire_model::BgAdaptInputs {
raw_color: wire.color,
canvas_color: true,
..Default::default()
}));
}
plot_owner_aci(scene, &mut wire);
crate::io::pdf_export::PlotWire { wire, draw_depth }
})
.collect::<Vec<_>>()
};
let paper_wires = with_depth(paper_wires);
let model_wires = with_depth(model_wires);
let paper_hatches = scene.paper_plot_hatches().as_ref().clone();
let paper_wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
let paper_images = scene.paper_plot_images();
if scene.current_layout == "Model" {
let splits = crate::io::pdf_export::PlotGroupSplits {
wires: paper_wires.len(),
hatches: paper_hatches.len(),
wipeouts: paper_wipeouts.len(),
images: paper_images.len(),
};
return PlotContent {
wires: std::sync::Arc::new(paper_wires),
hatches: paper_hatches,
wipeouts: paper_wipeouts,
images: paper_images,
group_splits: splits,
};
}
let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) =
scene.viewport_plot_fills();
model_pattern_wires.retain(|(wire, _)| wire.plot_visible);
let model_pattern_wires = model_pattern_wires
.into_iter()
.map(|(mut wire, draw_depth)| {
plot_owner_aci(scene, &mut wire);
crate::io::pdf_export::PlotWire { wire, draw_depth }
})
.collect::<Vec<_>>();
let (wires, hatches, wipeouts, images, 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(),
images: model_images.len(),
};
let mut wires = model_wires;
let mut hatches = model_hatches;
let mut wipeouts = model_wipeouts;
let mut images = model_images;
wires.extend(model_pattern_wires);
wires.extend(paper_wires);
hatches.extend(paper_hatches);
wipeouts.extend(paper_wipeouts);
images.extend(paper_images);
(wires, hatches, wipeouts, images, splits)
} else {
let splits = crate::io::pdf_export::PlotGroupSplits {
wires: paper_wires.len(),
hatches: paper_hatches.len(),
wipeouts: paper_wipeouts.len(),
images: paper_images.len(),
};
let mut wires = paper_wires;
let mut hatches = paper_hatches;
let mut wipeouts = paper_wipeouts;
let mut images = paper_images;
wires.extend(model_wires);
wires.extend(model_pattern_wires);
hatches.extend(model_hatches);
wipeouts.extend(model_wipeouts);
images.extend(model_images);
(wires, hatches, wipeouts, images, splits)
};
PlotContent { wires: std::sync::Arc::new(wires), hatches, wipeouts, images, group_splits: splits }
}
impl OpenCADStudio {
/// Persist exact ACIS bodies and kernel-derived edge caches before saving.
fn sync_solid_models_for_save(&mut self, i: usize) {
use codec::EntityType;
let scene = &mut self.tabs[i].scene;
let targets: Vec<(codec::Handle, bool, bool)> = scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Solid3D(solid) = entity else {
return None;
};
let h = solid.common.handle;
let needs_acis = !solid.acis_data.has_data();
let needs_wires = solid.wires.is_empty();
(needs_acis || needs_wires).then_some((h, needs_acis, needs_wires))
})
.collect();
for (h, needs_acis, needs_wires) in targets {
let body = scene.solid_models.get(&h);
let sat = needs_acis
.then(|| body.and_then(crate::scene::convert::acis_export::solid_to_sat))
.flatten();
let wires = needs_wires.then(|| {
if let Some(body) = body {
return crate::scene::model::solid_model::edge_wires(body);
}
let Some(mesh) = scene.meshes.get(&h).or_else(|| scene.block_meshes.get(&h)) else {
return Vec::new();
};
mesh.edge_verts
.chunks_exact(2)
.enumerate()
.map(|(index, points)| {
let first_low = mesh
.edge_verts_low
.get(index * 2)
.copied()
.unwrap_or([0.0; 3]);
let second_low = mesh
.edge_verts_low
.get(index * 2 + 1)
.copied()
.unwrap_or([0.0; 3]);
codec::entities::Wire::from_points(vec![
codec::types::Vector3::new(
points[0][0] as f64 + first_low[0] as f64,
points[0][1] as f64 + first_low[1] as f64,
points[0][2] as f64 + first_low[2] as f64,
),
codec::types::Vector3::new(
points[1][0] as f64 + second_low[0] as f64,
points[1][1] as f64 + second_low[1] as f64,
points[1][2] as f64 + second_low[2] as f64,
),
])
})
.collect()
});
if let Some(EntityType::Solid3D(solid)) = scene.document.get_entity_mut(h) {
if let Some(sat) = sat {
solid.set_sat_document(&sat);
}
if let Some(wires) = wires.filter(|wires| !wires.is_empty()) {
solid.wires = wires;
}
}
}
}
/// Write a header variable on the active drawing and mark it modified.
pub(in crate::app) fn set_drawing_var(
&mut self,
write: impl FnOnce(&mut codec::document::HeaderVariables),
) {
let i = self.active_tab;
if let Some(tab) = self.tabs.get_mut(i) {
write(&mut tab.scene.document.header);
tab.dirty = true;
}
}
/// Re-tessellate active-drawing solids after an ISOLINES change.
pub(in crate::app) fn regenerate_meshes(&mut self) {
let i = self.active_tab;
if let Some(tab) = self.tabs.get_mut(i) {
tab.scene.populate_meshes_from_document();
}
}
/// Rebuild text geometry in every open drawing.
///
/// `TEXTFILL` is a process-global, so a change to it invalidates the text
/// in every tab. Doing only the active one leaves the others rendering the
/// previous setting until some unrelated edit happens to rebuild them.
pub(in crate::app) fn invalidate_text_everywhere(&mut self) {
for tab in &mut self.tabs {
tab.scene.invalidate_text_geometry_dependencies();
}
}
/// Snapshot the persisted UI preferences from live state.
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
crate::app::settings::UserSettings {
spacemouse: self.spacemouse_preferences,
dyn_input: self.dyn_input,
polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg,
zoom_wheel_reversed: self.zoom_wheel_reversed,
zoom_factor: self.zoom_factor,
cursor_size: self.cursor_size,
pick_box: self.pick_box,
options_tab: self.options_tab,
show_viewcube: self.show_viewcube,
show_ucs_icon: self.show_ucs_icon,
ucs_icon_at_origin: self.ucs_icon_at_origin,
selection_cycling: self.selection_cycling,
double_click_block_refedit: self.double_click_block_refedit,
double_click_block_attedit: self.double_click_block_attedit,
right_click_mode: self.right_click_mode,
right_click_hold_ms: self.right_click_hold_ms,
grip_object_limit: self.grip_object_limit,
image_frame: crate::scene::frame::profile_image_mode(),
attdef_on_screen: crate::modules::draw::draw::attdef::session().on_screen,
field_display: crate::entities::field::display(),
ncopy_bind: self.ncopy_bind,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
lineweight_display_scale: self.lineweight_display_scale,
isometric_drafting: self.isometric_drafting,
iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg,
otrack: self.snapper.otrack_enabled,
default_assoc_prompted: self.default_assoc_prompted,
check_missing_fonts: self.check_missing_fonts,
font_source_url: self.font_source_url.clone(),
donation_prompt_version: self.donation_prompt_version.clone(),
gpu_warning_silenced: self.gpu_warning_silenced.clone(),
disabled_plugins: {
let mut v: Vec<String> = self.disabled_plugins.iter().cloned().collect();
v.sort();
v
},
plugin_repos: self.plugin_repos.clone(),
literal_spaces: self.command_line.literal_spaces,
command_history_height: self.command_line.history_height,
osmode: crate::app::settings::osmode_from_snaps(
self.snapper.enabled.iter(),
self.snapper.snap_enabled,
),
texteditmode: self.texteditmode,
quick_dimension_snap_priority: self.quick_dimension_snap_priority,
dimension_continue_mode: self.dimension_continue_mode,
delete_objects: self.delete_objects,
textfill: crate::scene::text::sdf_atlas::textfill(),
backup_on_save: self.backup_on_save,
file_assoc_enabled: self.file_assoc_enabled,
show_constraint_values: self.show_constraint_values,
auto_constrain: self.auto_constrain_settings.clone(),
constraint_solve_mode: self.constraint_solve_mode,
constraint_infer: self.constraint_infer,
constraint_bar_display: self.constraint_bar_display,
constraint_bar_mode: self.constraint_bar_mode,
savetime_min: self.savetime_min,
script_commands: self.script_commands,
default_save_format: self.default_save_format.clone(),
pick_add: self.pick_add,
pick_drag_rect: self.pick_drag_rect,
quick_properties: self.quick_properties,
language: self.language,
cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines),
commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms(
self.commandline_fade_ms,
),
snap_spacing_x: self.snapper.snap_spacing_x,
snap_spacing_y: self.snapper.snap_spacing_y,
grid_spacing_x: self.grid_spacing_x,
grid_spacing_y: self.grid_spacing_y,
grid_major_every: self.grid_major_every,
grid_adaptive: self.grid_adaptive,
grid_beyond_limits: self.grid_beyond_limits,
block_mru: self.block_mru.clone(),
block_freq: self.block_freq.clone(),
block_recent: self.block_palette.recent.clone(),
block_favorites: self.block_palette.favorites.clone(),
block_libraries: self.block_palette.libraries.clone(),
block_palette_view: self.block_palette.view as u8,
block_insert: self.block_palette.options.clone(),
block_mru_list: self.block_mru_list,
block_redefine_mode: self.block_redefine_mode,
block_navigate: self.block_navigate.clone(),
count_color: self.count_palette.color,
count_error_color: self.count_palette.error_color,
count_service: self.count_palette.service,
}
}
/// Apply restored preferences to live state.
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
self.dyn_input = s.dyn_input;
self.polar_mode = s.polar;
self.polar_increment_deg = s.polar_increment_deg;
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
self.zoom_factor = crate::app::settings::clamp_zoom_factor(s.zoom_factor);
// Like the drafting-rotation field below, the Options field edits a
// buffer rather than the value, so it is reseeded whenever the value
// is restored from behind it — a start-up, or the Options window's
// own Close putting a change back.
self.zoom_factor_input = self.zoom_factor.to_string();
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.options_tab = s.options_tab;
self.spacemouse_preferences = s.spacemouse;
// These four drive real features with commands and status-bar pills,
// but they lived only on the app struct: turning the ViewCube off and
// restarting brought it straight back.
self.show_viewcube = s.show_viewcube;
self.show_ucs_icon = s.show_ucs_icon;
self.ucs_icon_at_origin = s.ucs_icon_at_origin;
self.selection_cycling = s.selection_cycling;
self.double_click_block_refedit = s.double_click_block_refedit;
self.double_click_block_attedit = s.double_click_block_attedit;
self.right_click_mode = s.right_click_mode;
self.right_click_hold_ms = super::super::settings::clamp_right_click_hold_ms(s.right_click_hold_ms);
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
crate::scene::frame::set_profile_image_mode(s.image_frame);
crate::modules::draw::draw::attdef::session().on_screen = s.attdef_on_screen;
crate::entities::field::set_display(s.field_display);
self.ncopy_bind = s.ncopy_bind;
self.cursor_type = s.cursor_type;
self.crosshair_color = s.crosshair_color;
self.crosshair_color_input = s
.crosshair_color
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200);
self.isometric_drafting = s.isometric_drafting;
self.iso_plane = s.iso_plane;
self.snap_angle_deg = if s.snap_angle_deg.is_finite() {
s.snap_angle_deg.rem_euclid(360.0)
} else {
0.0
};
// The Options field edits a buffer rather than the value, so it has to
// be reseeded whenever the value is restored from behind it.
self.snap_angle_input = crate::app::settings::format_snap_angle(self.snap_angle_deg);
// Ortho + running OSNAP are per-drawing (adopted from the header on
// open / tab switch), not app-global, so they are not applied here.
self.snapper.otrack_enabled = s.otrack;
self.default_assoc_prompted = s.default_assoc_prompted;
self.check_missing_fonts = s.check_missing_fonts;
self.font_source_url = s.font_source_url.clone();
self.font_source_input = s.font_source_url.clone();
self.donation_prompt_version = s.donation_prompt_version.clone();
self.gpu_warning_silenced = s.gpu_warning_silenced.clone();
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
self.plugin_repos = s.plugin_repos.clone();
self.command_line.literal_spaces = s.literal_spaces;
self.command_line.history_height = if s.command_history_height.is_finite() {
s.command_history_height.clamp(
crate::ui::command_line::HISTORY_HEIGHT_MIN,
crate::ui::command_line::HISTORY_HEIGHT_MAX,
)
} else {
crate::ui::command_line::HISTORY_HEIGHT_DEFAULT
};
let (modes, snap_enabled) = crate::app::settings::snaps_from_osmode(s.osmode);
self.snapper.enabled = modes.into_iter().collect();
self.snapper.snap_enabled = snap_enabled;
self.texteditmode = s.texteditmode;
self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1);
self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1);
self.delete_objects = s.delete_objects.clamp(0, 3);
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
self.backup_on_save = s.backup_on_save;
self.file_assoc_enabled = s.file_assoc_enabled;
self.show_constraint_values = s.show_constraint_values;
self.auto_constrain_settings = s.auto_constrain.clone();
self.auto_constrain_settings.sanitize();
self.auto_constrain_distance_input =
format!("{}", self.auto_constrain_settings.distance_tolerance);
self.auto_constrain_angle_input =
format!("{}", self.auto_constrain_settings.angle_tolerance_deg);
self.constraint_solve_mode = s.constraint_solve_mode;
self.constraint_infer = s.constraint_infer;
self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3);
self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095);
self.savetime_min = s.savetime_min;
self.script_commands = s.script_commands;
self.default_save_format =
crate::io::canonical_save_format(&s.default_save_format).to_string();
self.pick_add = s.pick_add;
self.pick_drag_rect = s.pick_drag_rect;
self.quick_properties = s.quick_properties;
if crate::i18n::set_language(s.language).is_ok() {
self.language = s.language;
}
self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines);
self.command_line
.set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8);
self.commandline_fade_ms =
crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms);
self.command_line
.set_commandline_fade_ms(self.commandline_fade_ms.clamp(0, 60000) as u32);
self.snapper.snap_spacing_x =
crate::app::settings::sanitize_snap_spacing(s.snap_spacing_x);
self.snapper.snap_spacing_y =
crate::app::settings::sanitize_snap_spacing(s.snap_spacing_y);
self.grid_spacing_x = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_x);
self.grid_spacing_y = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_y);
self.grid_major_every = crate::app::settings::sanitize_grid_major(s.grid_major_every);
self.grid_adaptive = s.grid_adaptive;
self.grid_beyond_limits = s.grid_beyond_limits;
// Block usage: clone but cap to sane sizes (MRU 20, freq map 200)
self.block_mru = s.block_mru.iter().take(20).cloned().collect();
self.block_freq = s
.block_freq
.iter()
.take(200)
.map(|(k, v)| (k.clone(), *v))
.collect();
self.block_mru_list = s.block_mru_list.min(100);
self.block_redefine_mode = s.block_redefine_mode.min(2);
self.block_navigate = s.block_navigate.clone();
self.count_palette.color = s.count_color;
self.count_palette.error_color = s.count_error_color;
self.count_palette.service = s.count_service;
self.block_palette.recent = s.block_recent.clone();
self.block_palette.favorites = s.block_favorites.clone();
self.block_palette.libraries = s.block_libraries.clone();
self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view);
self.block_palette.set_options(s.block_insert.clone());
self.trim_recent_blocks();
// Push restored display defaults onto every drawing tab that exists now.
// Tabs created later pick them up at their construction site.
for idx in 0..self.tabs.len() {
self.apply_display_defaults(idx);
}
self.rebuild_ribbon_modules();
}
pub(crate) fn record_block_insert(&mut self, name: &str) {
self.insname = name.to_string();
self.note_recent_block(name);
let key = name.to_ascii_uppercase();
*self.block_freq.entry(key).or_insert(0) += 1;
self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name));
self.block_mru.insert(0, name.to_string());
if self.block_mru.len() > 20 {
self.block_mru.truncate(20);
}
// Cap freq map to 200 most frequent to bound persistence size.
if self.block_freq.len() > 200 {
let mut items: Vec<(String, u32)> = self
.block_freq
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
items.truncate(200);
self.block_freq = items.into_iter().collect();
}
// Debounce disk writes: at most once per second (2.4) to avoid thrash
// during rapid scripting/batch inserts. Immediate first write ensures
// crash recovery. Wasm has no `std::time::Instant` guarantee, so persist
// immediately there.
#[cfg(not(target_arch = "wasm32"))]
{
let now = std::time::Instant::now();
let should_persist = match self.block_usage_last_persist {
None => true,
Some(t) => now.duration_since(t).as_secs_f32() >= 1.0,
};
if should_persist {
self.block_usage_last_persist = Some(now);
self.persist_settings_if_changed();
}
}
#[cfg(target_arch = "wasm32")]
{
self.persist_settings_if_changed();
}
}
pub(crate) fn block_usage_snapshot(&self) -> rustc_hash::FxHashMap<String, (u32, usize)> {
let mut out = rustc_hash::FxHashMap::default();
for (idx, name) in self.block_mru.iter().enumerate() {
let up = name.to_ascii_uppercase();
let freq = self.block_freq.get(&up).copied().unwrap_or(0);
out.insert(up, (freq, idx));
}
// Ensure every freq entry has at least an entry (for blocks not in MRU)
for (up, freq) in &self.block_freq {
out.entry(up.clone()).or_insert((*freq, usize::MAX));
}
out
}
pub(crate) fn ranked_block_names(&self, names: &[String]) -> Vec<String> {
let mut scored: Vec<(&String, u32, usize)> = names
.iter()
.map(|n| {
let up = n.to_ascii_uppercase();
let f = self.block_freq.get(&up).copied().unwrap_or(0);
let m = self
.block_mru
.iter()
.position(|x| x.eq_ignore_ascii_case(n))
.unwrap_or(usize::MAX);
(n, f, m)
})
.collect();
scored.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.2.cmp(&b.2))
.then_with(|| a.0.cmp(b.0))
});
scored.into_iter().map(|(n, _, _)| n.clone()).collect()
}
/// Adopt the per-drawing sysvars stored in tab `i`'s document header —
/// Ortho (`$ORTHOMODE`) and the running object-snap set (`$OSMODE`) — into
/// the live app state, so each drawing keeps its own. Called when a drawing
/// becomes active (open, tab switch). The app-settings values seed the boot
/// default; the per-file header overrides it here.
pub(in crate::app) fn adopt_header_sysvars(&mut self, i: usize) {
if self.tabs[i].is_start {
return;
}
let (ortho, osmode) = {
let h = &self.tabs[i].scene.document.header;
(h.ortho_mode, h.object_snap_mode)
};
self.ortho_mode = ortho;
// Ortho and Polar are mutually exclusive (ToggleOrtho clears Polar).
if ortho {
self.polar_mode = false;
}
// OSMODE has no file slot in modern DWG (R2000+ moved it to the
// registry), so a header value of 0 just means "absent" — keep the
// user's app-level set instead of wiping it. Only a legacy R13/R14 or
// DXF file that really carries a nonzero mask overrides.
if osmode != 0 {
let (modes, snap_enabled) = crate::app::settings::snaps_from_osmode(osmode);
self.snapper.enabled = modes.into_iter().collect();
self.snapper.snap_enabled = snap_enabled;
}
}
/// Stamp the live per-drawing sysvars (Ortho, running OSNAP) onto tab `i`'s
/// document header just before it is written to disk (or before switching
/// away from it), so they persist with the drawing.
pub(in crate::app) fn stamp_header_sysvars(&mut self, i: usize) {
if self.tabs[i].is_start {
return;
}
let osmode = crate::app::settings::osmode_from_snaps(
self.snapper.enabled.iter(),
self.snapper.snap_enabled,
);
let h = &mut self.tabs[i].scene.document.header;
h.ortho_mode = self.ortho_mode;
h.object_snap_mode = osmode;
}
/// Apply persisted viewport display defaults to tab `idx`.
pub(in crate::app) fn apply_display_defaults(&mut self, idx: usize) {
let bg = self.default_bg_color;
let paper_bg = self.default_paper_bg_color;
let tab = &mut self.tabs[idx];
if tab.is_start {
return;
}
tab.scene.model_lineweight_scale = self.lineweight_display_scale as f32 / 100.0;
if bg.is_none() && paper_bg.is_none() {
return;
}
if let Some(c) = bg {
tab.bg_color = Some(c);
tab.scene.bg_color = c;
}
if let Some(c) = paper_bg {
tab.paper_bg_color = Some(c);
tab.scene.paper_bg_color = c;
}
tab.scene.recolor_meshes();
tab.scene.bump_geometry();
}
/// Check if a suspended command exists on the active tab and resume it
/// with the outcome of the text editor.
pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task<Message> {
self.reset_modal_geometry();
let i = self.active_tab;
if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() {
if committed {
if let Some(value) = self.pending_command_editor_text.take() {
cmd.on_editor_text(value);
}
} else {
self.pending_command_editor_text = None;
}
let res = cmd.on_editor_closed(committed);
self.tabs[i].active_cmd = Some(cmd);
self.apply_cmd_result(res)
} else {
self.focus_cmd_input()
}
}
/// Rebuild the ribbon's tab list from the registry, dropping the tabs of any
/// disabled plugins. Call after `disabled_plugins` changes.
pub(in crate::app) fn rebuild_ribbon_modules(&mut self) {
let modules = crate::plugin::ribbon_modules_enabled(&self.disabled_plugins);
self.ribbon.set_modules(modules);
// Refresh the command-line autocomplete pool so a newly enabled plugin's
// commands become typeable (and a disabled one's drop out). This runs on
// startup load, settings reload, and every enable/disable toggle (#272).
self.command_line.dynamic_commands =
crate::plugin::plugin_command_names(&self.disabled_plugins);
}
/// Snapshot of disabled plugin ids — lets the registry skip them while it
/// holds a `&mut` borrow of the app via `HostSession`.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn disabled_plugin_ids(&self) -> rustc_hash::FxHashSet<String> {
self.disabled_plugins.clone()
}
/// Background task: fetch the curated plugin registry.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_registry_task(&self) -> Task<Message> {
Task::perform(
async { crate::plugin::marketplace::fetch_registry() },
Message::PluginRegistryFetched,
)
}
/// Background task: fetch `owner/repo`'s installable releases and their
/// manifest API versions. The fetch runs on its own OS thread because the
/// several sequential HTTP requests inside `fetch_release_info` would
/// otherwise block the async executor and serialise all repo fetches.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
let label = repo.clone();
Task::perform(
async move {
let (tx, rx) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = crate::plugin::marketplace::fetch_release_info(&repo);
let _ = tx.send(result);
});
rx.await
.unwrap_or_else(|_| Err("release fetch thread died".into()))
},
move |res| Message::PluginReleasesFetched(label, res),
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
Task::done(Message::PluginReleasesFetched(
repo,
Err("External plugins are available in the desktop app.".to_string()),
))
}
/// Background task: fetch a repository README from its default branch.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_plugin_readme_task(&self, repo: String) -> Task<Message> {
let label = repo.clone();
Task::perform(
async move { crate::plugin::marketplace::fetch_readme(&repo) },
move |result| Message::PluginReadmeFetched(label, result),
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn fetch_plugin_readme_task(&self, repo: String) -> Task<Message> {
Task::done(Message::PluginReadmeFetched(
repo,
Err("Plugin details are available in the desktop app.".to_string()),
))
}
/// Background task: download and install the `tag` release of `owner/repo`.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<Message> {
Task::perform(
async move {
let releases = crate::plugin::marketplace::fetch_releases(&repo)?;
let rel = releases
.into_iter()
.find(|r| r.tag == tag)
.ok_or_else(|| format!("release {tag} not found"))?;
crate::plugin::marketplace::install(&rel, &repo)
},
Message::PluginInstalled,
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn install_task(&self, _repo: String, _tag: String) -> Task<Message> {
Task::done(Message::PluginInstalled(Err(
"External plugins are available in the desktop app.".to_string(),
)))
}
/// Gather the full persisted config (all sections) from live app state.
pub(in crate::app) fn current_config(&self) -> crate::app::config::AppConfig {
crate::app::config::AppConfig {
settings: self.current_settings(),
theme: self.ui_theme.clone(),
recent: crate::app::config::RecentConfig {
files: self
.recent_files
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
limit: self.recent_limit,
},
start: crate::app::config::StartConfig {
section: self.start_section,
},
statusbar: self.statusbar_config.clone(),
dock: {
let mut dock = self.dock.clone();
dock.ensure_settings();
dock
},
annotation_auto_scale: self.annotation_auto_scale,
ribbon: crate::app::config::RibbonConfig {
collapse: self.ribbon.collapse_mode(),
},
plot: self.plot_dialog.clone(),
shortcuts: crate::app::config::ShortcutConfig {
bindings: self
.shortcut_bindings
.iter()
.map(|(key, command)| (key.clone(), command.clone()))
.collect(),
},
model_space: self.model_space.clone(),
}
}
/// Distribute a loaded config into live app state (called once at startup).
pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) {
self.apply_settings(&cfg.settings);
self.ui_theme = cfg.theme.clone();
self.active_theme = self.ui_theme.to_iced();
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.model_space = cfg.model_space;
self.model_bg_input = self
.model_space
.custom_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.paper_bg_input = self
.model_space
.custom_paper_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.desk_bg_input = self
.model_space
.custom_desk_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.sync_model_space_theme(false);
self.recent_files = cfg
.recent
.files
.iter()
.map(std::path::PathBuf::from)
.collect();
self.recent_limit = cfg.recent.limit.clamp(
crate::app::recent::RECENT_MIN,
crate::app::recent::RECENT_MAX,
);
self.recent_files.truncate(self.recent_limit);
self.recent_limit_input = self.recent_limit.to_string();
// Thumbnails are decoded by a background task queued at boot
// (`refresh_recent_thumbs`) — never here on the boot path.
self.start_section = cfg.start.section;
self.statusbar_config = cfg.statusbar;
let mut dock = cfg.dock;
dock.ensure_settings();
self.dock = dock;
self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4);
self.ribbon.set_collapse_mode(cfg.ribbon.collapse);
self.plot_dialog = cfg.plot;
self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect();
self.shortcut_bindings
.entry("F5".to_string())
.or_insert_with(|| "ISOPLANE".to_string());
}
/// Write the config only when it changed since the last write, so a toggle
/// persists immediately without thrashing native or browser storage.
pub(in crate::app) fn save_config(&mut self) {
let cur = self.current_config();
if self.last_saved_config.as_ref() != Some(&cur) {
cur.save();
self.last_saved_config = Some(cur);
}
}
/// Back-compat name for the many "a preference changed, persist it" sites.
pub(in crate::app) fn persist_settings_if_changed(&mut self) {
// Keep resize feedback live without writing settings on every pointer
// move. The release message saves the final height once.
if !self.command_history_resizing {
self.save_config();
}
}
/// Sync the active theme and model space configuration into all open tabs
/// and the application default background settings.
pub(in crate::app) fn sync_model_space_theme(&mut self, geometry_changed: bool) {
let model_bg = self.model_space.resolve_model_bg(&self.active_theme);
let paper_bg = self.model_space.resolve_paper_bg();
let sel_color = self.model_space.resolve_selection_color();
let sel_effect = self.model_space.selection_effect;
self.default_bg_color = Some(model_bg);
self.default_paper_bg_color = Some(paper_bg);
for tab in &mut self.tabs {
let old_bg = tab.scene.bg_color;
let old_paper = tab.scene.paper_bg_color;
let old_sel_color = tab.scene.selection_color;
let old_sel_effect = tab.scene.selection_effect;
tab.scene.bg_color = model_bg;
tab.scene.paper_bg_color = paper_bg;
tab.scene.selection_color = sel_color;
tab.scene.selection_effect = sel_effect;
tab.bg_color = Some(model_bg);
tab.paper_bg_color = Some(paper_bg);
if old_sel_color != sel_color || old_sel_effect != sel_effect {
tab.scene.selection_generation = tab.scene.selection_generation.wrapping_add(1);
}
if geometry_changed || old_bg != model_bg || old_paper != paper_bg {
tab.scene.recolor_meshes();
tab.scene.bump_geometry();
}
tab.scene
.request_refresh(crate::scene::ViewportRefreshScope::All);
}
}
/// Record that the one-time default-association prompt has been answered and
/// flush it to disk, so the dialog never reappears on later launches.
pub(in crate::app) fn mark_assoc_prompted(&mut self) {
self.default_assoc_prompted = true;
self.persist_settings_if_changed();
}
pub(super) fn on_open_file(&mut self) -> Task<Message> {
// Native: pick a path, then load on a worker thread. Web: the
// browser hands back bytes, so pick + parse in one step and feed
// the shared `FileOpened` handler directly.
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
}
#[cfg(target_arch = "wasm32")]
{
// The browser picker + parse happen inside `pick_and_load_web`;
// the real name is unknown until then, so show a generic label
// meanwhile.
let (open_id, state) = self.begin_web_open("Opening…".into(), 0);
Task::perform(crate::io::pick_and_load_web(state), move |outcome| {
Message::WebFileOpened(open_id, outcome)
})
}
}
/// Web: open a drawing from bytes the caller already holds, through the
/// same path as a file chosen in the browser picker.
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn open_web_bytes(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
let (open_id, state) = self.begin_web_open(name.clone(), bytes.len() as u64);
Task::perform(
crate::io::open_bytes_web(name, std::sync::Arc::from(bytes), state),
move |outcome| Message::WebFileOpened(open_id, outcome),
)
}
/// `FileOpened` only installs the result when an open is in progress, so
/// mark one.
#[cfg(target_arch = "wasm32")]
fn begin_web_open(
&mut self,
name: String,
size_bytes: u64,
) -> (u64, std::sync::Arc<crate::io::OpenProgressState>) {
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
let open_id = self.next_open_id();
self.opening = Some(crate::app::OpenProgress {
id: open_id,
name,
source_path: None,
size_bytes,
state: state.clone(),
started: Instant::now(),
recovery_error: None,
recovery_read_stats: None,
recovery_bytes: None,
});
(open_id, state)
}
pub(in crate::app) fn next_open_id(&mut self) -> u64 {
self.open_job_serial = self.open_job_serial.wrapping_add(1).max(1);
self.open_job_serial
}
/// Index of a tab already showing `path`, or `None`.
///
/// Compares resolved paths, so the same drawing reached through a symlink,
/// a `..` segment or a different relative spelling is recognised as the one
/// already open rather than loaded a second time. A path that cannot be
/// resolved (deleted since) matches nothing and falls through to the normal
/// open, which reports the miss.
pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option<usize> {
#[cfg(target_arch = "wasm32")]
{
return self
.tabs
.iter()
.position(|tab| tab.current_path.as_deref() == Some(path));
}
#[cfg(not(target_arch = "wasm32"))]
{
let want = std::fs::canonicalize(path).ok()?;
self.tabs.iter().position(|t| {
t.current_path
.as_deref()
.and_then(|p| std::fs::canonicalize(p).ok())
.is_some_and(|p| p == want)
})
}
}
/// Read through this session's lease when it covers `path`.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn read_drawing(&self, path: &std::path::Path) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let lease = self
.tab_showing(path)
.and_then(|i| self.tabs[i].edit_lease.as_ref());
let leased = match lease {
Some(lease) => lease.reader()?,
None => None,
};
let Some(mut reader) = leased else {
return std::fs::read(path);
};
reader.seek(SeekFrom::Start(0))?;
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
Ok(bytes)
}
/// Start the next drawing a second launch handed us, if any.
///
/// Must be called from EVERY path that clears `opening` — completion, error
/// and cancel alike. Draining only the success path would strand the queue
/// forever the first time a file fails to parse.
pub(in crate::app) fn drain_pending_open(&mut self) -> Task<Message> {
match self.pending_opens.pop_front() {
Some(p) => Task::done(Message::OpenExternal(p)),
None => Task::none(),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn install_native_edit_guard(
&mut self,
i: usize,
path: &std::path::Path,
loaded_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
) {
let current_fingerprint = crate::io::edit_lock::FileFingerprint::capture(path).ok();
if loaded_fingerprint
.as_ref()
.zip(current_fingerprint.as_ref())
.is_some_and(|(loaded, current)| loaded != current)
{
self.command_line.push_error_once(
crate::t!(
"Drawing changed on disk while it was opening; Save will require conflict resolution."
)
.as_ref(),
);
}
self.tabs[i].disk_fingerprint = loaded_fingerprint.or(current_fingerprint);
match crate::io::edit_lock::EditLease::acquire(path) {
Ok(lease) => {
if let Some(warning) = lease.platform_warning() {
self.command_line.push_info(
crate::tf!(
"Edit lease active; {warning}. External-change checks remain active."
)
.as_ref(),
);
}
self.tabs[i].edit_lease = Some(lease);
self.tabs[i].edit_lock_conflict = false;
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lock_conflict = true;
self.command_line.push_error_once(
crate::tf!("Opened read-only against other editors: {error}").as_ref(),
);
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(
crate::tf!("{error}. External-change checks remain active.").as_ref(),
);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn refresh_native_edit_guard_after_save(
&mut self,
i: usize,
path: &std::path::Path,
path_changed: bool,
destination_lease: Option<crate::io::edit_lock::EditLease>,
) {
if path_changed {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lease = destination_lease;
self.tabs[i].edit_lock_conflict = false;
if self.tabs[i].edit_lease.is_none() {
self.install_native_edit_guard(i, path, None);
return;
}
}
let refresh = self.tabs[i].edit_lease.as_mut().map(|lease| {
lease
.refresh_drawing_lock(path)
.map(|_| lease.platform_warning().map(str::to_owned))
});
match refresh {
Some(Ok(warning)) => {
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(
crate::tf!("{warning}. External-change checks remain active.").as_ref(),
);
}
}
Some(Err(crate::io::edit_lock::EditLeaseError::Locked(error))) => {
self.tabs[i].edit_lock_conflict = true;
self.command_line.push_error_once(
crate::tf!(
"Saved, but the refreshed drawing is locked by another editor: {error}"
)
.as_ref(),
);
}
Some(Err(crate::io::edit_lock::EditLeaseError::Unavailable(error))) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(
crate::tf!("{error}. External-change checks remain active.").as_ref(),
);
}
None => self.install_native_edit_guard(i, path, None),
}
self.tabs[i].disk_fingerprint = crate::io::edit_lock::FileFingerprint::capture(path).ok();
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn save_tab_synchronously_protected(
&mut self,
i: usize,
path: std::path::PathBuf,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
let version = self.tabs[i].scene.document.version;
self.save_tab_synchronously_protected_as(i, path, version, set_current_path)
}
/// Synchronous protected save with an explicit output version. Automation
/// uses this path so format conversion never depends on an implicit source
/// version or silently upgrades to the newest DWG.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn save_tab_synchronously_protected_as(
&mut self,
i: usize,
path: std::path::PathBuf,
version: codec::DxfVersion,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
let previous_autosave = self.autosave_target(i);
if self.tabs[i].recovery_save_as_required
&& self.tabs[i]
.current_path
.as_deref()
.is_some_and(|source| native_paths_match(source, &path))
{
return Err(crate::io::SaveFailure::other(
"repaired drawing must be saved to a new file",
));
}
let path_changed = self.tabs[i]
.current_path
.as_deref()
.is_none_or(|current| !native_paths_match(current, &path));
if !path_changed && self.tabs[i].edit_lock_conflict {
return Err(crate::io::SaveFailure::file_in_use(
"drawing edit lock is held by another editor",
));
}
let mut destination_lease = if path_changed {
match crate::io::edit_lock::EditLease::acquire(&path) {
Ok(lease) => Some(lease),
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
return Err(crate::io::SaveFailure::file_in_use(error));
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.command_line.push_info(
crate::tf!("{error}. External-change checks remain active.").as_ref(),
);
None
}
}
} else {
None
};
let expected_fingerprint = if path_changed {
None
} else {
self.tabs[i].disk_fingerprint.clone()
};
let lease = if path_changed {
destination_lease.as_mut()
} else {
self.tabs[i].edit_lease.as_mut()
};
let (expected_fingerprint, verify_reader) =
Self::native_save_verification(&path, lease, expected_fingerprint)?;
self.prepare_native_save(i);
let snapshot = self.tabs[i].scene.document_for_save();
crate::io::save_owned_as_version_atomic(
snapshot,
&path,
version,
self.backup_on_save,
expected_fingerprint,
verify_reader,
)?;
if set_current_path {
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
}
self.refresh_native_edit_guard_after_save(i, &path, path_changed, destination_lease);
self.tabs[i].dirty = false;
if set_current_path {
self.tabs[i].recovery_save_as_required = false;
}
let _ = std::fs::remove_file(previous_autosave);
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn retry_native_edit_guard(&mut self, i: usize, path: &std::path::Path) -> Result<(), String> {
if let Some(lease) = self.tabs[i].edit_lease.as_mut() {
match lease.refresh_drawing_lock(path) {
Ok(()) => {
let warning = lease.platform_warning().map(str::to_owned);
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(
crate::tf!("{warning}. External-change checks remain active.").as_ref(),
);
}
return Ok(());
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
return Err(error);
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(
crate::tf!("{error}. External-change checks remain active.").as_ref(),
);
return Ok(());
}
}
}
match crate::io::edit_lock::EditLease::acquire(path) {
Ok(lease) => {
let warning = lease.platform_warning().map(str::to_owned);
self.tabs[i].edit_lease = Some(lease);
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(
crate::tf!("{warning}. External-change checks remain active.").as_ref(),
);
}
Ok(())
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => Err(error),
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(
crate::tf!("{error}. External-change checks remain active.").as_ref(),
);
Ok(())
}
}
}
pub(super) fn on_file_opened(
&mut self,
name: String,
path: std::path::PathBuf,
doc: codec::CadDocument,
mut caches: crate::scene::DerivedCaches,
) -> Task<Message> {
// If the user clicked Cancel while the parser was running, the
// overlay state was cleared and we silently drop the result.
if self.opening.is_none() {
return Task::none();
}
let open_started = self.opening.as_ref().map(|p| p.started);
let size_bytes = self
.opening
.as_ref()
.map(|progress| progress.size_bytes)
.unwrap_or(0);
let timings = caches.timings;
let entity_count = doc.entities().count();
let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| {
stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed
}) || doc
.notifications
.iter()
.any(|item| item.notification_type == codec::notification::NotificationType::Error);
let reference_recovered = caches
.xrefs
.iter()
.any(|item| item.status == crate::io::xref::XrefStatus::Recovered);
let reference_failed = caches
.xrefs
.iter()
.any(|item| item.status == crate::io::xref::XrefStatus::Failed);
let document_repaired = parser_errors_recovered
|| reference_recovered
|| caches.corrupt_dropped > 0
|| caches.xref_dropped > 0;
let recovery_needed = document_repaired || reference_failed;
let total_ms = open_started
.map(|started| started.elapsed().as_millis() as u32)
.unwrap_or(0);
self.command_line
.push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref());
if caches.corrupt_dropped > 0 {
self.command_line.push_error(
crate::tf!(
"Warning: {} corrupt entities dropped (parser junk — bad normals / counts)",
caches.corrupt_dropped
)
.as_ref(),
);
}
if caches.xref_dropped > 0 {
self.command_line.push_error(
crate::tf!(
"Warning: {} corrupt xref entities dropped",
caches.xref_dropped
)
.as_ref(),
);
}
for info in &caches.xrefs {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line
.push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref());
}
crate::io::xref::XrefStatus::Recovered => {
self.command_line.push_error(
crate::tf!("XREF Recovered with warnings: \"{}\"", info.name).as_ref(),
);
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(
crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(),
);
}
crate::io::xref::XrefStatus::Failed => {
self.command_line.push_error(
crate::tf!("XREF Recovery failed: \"{}\" ({})", info.name, info.path)
.as_ref(),
);
}
crate::io::xref::XrefStatus::Unloaded => {
self.command_line.push_info(
crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref(),
);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
let thumbs_task = self.push_recent(path.clone());
#[cfg(target_arch = "wasm32")]
let thumbs_task = Task::none();
let current_is_empty = {
let t = &self.tabs[self.active_tab];
!t.is_start
&& t.current_path.is_none()
&& !t.dirty
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
};
let i = if current_is_empty {
self.active_tab
} else {
self.tab_counter += 1;
let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
self.tabs.push(new_tab);
let idx = self.tabs.len() - 1;
self.active_tab = idx;
self.apply_display_defaults(idx);
idx
};
// Missing-on-open: neutral command-line notice plus a flag
// the Reference Manager reads. Never an auto-open modal.
let missing = caches
.xrefs
.iter()
.filter(|item| item.status == crate::io::xref::XrefStatus::NotFound)
.count();
self.tabs[i].xref_missing = missing;
if missing > 0 {
self.command_line.push_output(crate::tf!(
"{} reference(s) not found — open the reference manager with EXTERNALREFERENCES.",
missing
).as_ref());
}
let mut recovery_report = recovery_needed.then(|| {
crate::io::recovery::RecoveryReport::recovered(
self.tabs[i].id,
&path,
size_bytes,
caches.source_sha256.clone(),
caches.read_stats.clone(),
entity_count.saturating_add(caches.corrupt_dropped),
caches.corrupt_dropped,
caches.xref_dropped,
&caches.xrefs,
&doc.notifications,
document_repaired,
timings,
total_ms,
)
});
if let Some(report) = recovery_report.as_mut() {
report.persist();
}
#[cfg(not(target_arch = "wasm32"))]
let opened_fingerprint = self
.opening
.as_ref()
.and_then(|opening| opening.fingerprint.clone());
self.tabs[i].current_path = Some(path.clone());
#[cfg(not(target_arch = "wasm32"))]
self.install_native_edit_guard(i, &path, opened_fingerprint);
self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf);
self.tabs[i].scene.document = doc;
self.tabs[i].scene.bump_layout_epoch();
self.tabs[i].scene.bump_scale_epoch();
// Load parameters first so imported dimensional constraints
// can resolve their named driving values.
self.tabs[i].scene.load_named_parameters_from_document();
self.tabs[i]
.scene
.load_parametric_constraints_from_document();
self.tabs[i].active_layer = self.tabs[i]
.scene
.document
.header
.current_layer_name
.clone();
// A file saved without the built-in Standard styles (foreign
// or damaged) gets them re-seeded so nothing dangles (#366).
crate::app::style_ops::ensure_standard_styles(&mut self.tabs[i].scene.document);
// 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 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
// glyph cache if it changed so Han re-resolves to the new
// language's font; geometry is (re)built below regardless. (#141)
if crate::scene::text::web_font::set_cjk_lang_from_codepage(
&self.tabs[i].scene.document.header.code_page,
) {
crate::scene::text::ttf_glyph::clear_fallback_cache();
}
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into
// drawing units as well: metric annotation sizes are paper millimetres
// and imperial annotation sizes are paper inches.
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into
// the drawing's INSUNITS as well, so e.g. a metre drawing uses
// 0.001 model units for 1 mm of paper at 1:1.
let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value;
let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor();
self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 {
((1.0 / cannoscale_value) / unit_factor) as f32
} else {
(1.0 / unit_factor) as f32
};
// Open-time breakdown so regressions are visible immediately.
// `total` is wall time from the Open click to here (post-xref,
// pre-first-frame); the phase figures are the background-thread
// parse/purge/cache spans plus the UI-thread xref resolve.
self.command_line.push_info(
crate::tf!(
" parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms",
timings.parse_ms,
timings.purge_ms,
timings.caches_ms,
timings.xref_ms,
total_ms
)
.as_ref(),
);
// Caches were built on the background thread inside open_path().
self.tabs[i].scene.local_extent_max = caches.local_extent_max;
self.tabs[i].scene.local_center = caches.local_center;
self.tabs[i].scene.hatches = caches.hatches;
self.tabs[i].scene.images = caches.images;
self.tabs[i].scene.meshes = caches.meshes;
self.tabs[i].scene.block_meshes = caches.block_meshes;
self.tabs[i].scene.object_data_cache = caches.object_data;
let prepared_geometry = caches.prepared_geometry.take();
// Invalidate the wire cache so the new document is tessellated.
self.tabs[i].scene.bump_geometry();
if let Some(prepared) = prepared_geometry {
self.tabs[i].scene.install_prepared_open_geometry(prepared);
}
// Pay for the interaction index here, not on the first hover.
self.tabs[i].scene.warm_interaction_caches();
self.tabs[i]
.scene
.replace_selection(rustc_hash::FxHashSet::default());
self.tabs[i].scene.preview_wires = vec![];
// Reopen in whichever space the file was saved in — the CTAB
// tab name when recorded, else the $TILEMODE model/paper flag —
// instead of always landing in Model.
{
let names = self.tabs[i].scene.layout_names();
let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document)
.and_then(|n| names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned());
self.tabs[i].scene.current_layout = match saved {
Some(n) => n,
None if self.tabs[i].scene.document.header.show_model_space => "Model".to_string(),
// Paper was active but no CTAB — fall back to the first
// paper layout (names[0] is always "Model").
None => names
.into_iter()
.nth(1)
.unwrap_or_else(|| "Model".to_string()),
};
self.tabs[i].scene.load_current_layout_state();
self.tabs[i].refresh_active_ucs();
}
// Object isolation is session-only. A newly opened drawing must
// not inherit the previous tab's filter, and persisted entity
// visibility remains independent (not an isolation session).
self.tabs[i].scene.reset_transient_visibility();
crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document);
self.tabs[i].properties = PropertiesPanel::empty();
// Seed the current table / multileader style from the file's
// header so the ✓ marks the right one (text/dim/mline come from
// the document header directly). DXF provides these via
// $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard".
self.ribbon.active_table_style = self.tabs[i]
.scene
.document
.header
.current_table_style_name
.clone();
self.tabs[i].active_mleader_style = self.tabs[i]
.scene
.document
.header
.current_mleader_style_name
.clone();
let doc_layers = self.tabs[i].scene.document.layers.clone();
let vp_info = self.tabs[i].scene.viewport_list();
self.tabs[i]
.layers
.sync_with_viewports(&doc_layers, vp_info);
self.sync_ribbon_layers();
// Load the Annotate-ribbon style dropdowns (text / dimension /
// multileader / table) from the opened document instead of
// leaving them on the hard-coded "Standard" default.
self.sync_ribbon_styles();
// Reset the Home-ribbon Color / Linetype / Lineweight chips
// to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT
// defaults (or to ByLayer when the file leaves them empty).
// Without this they stick to whatever the prior tab had
// selected — see #21.
self.sync_ribbon_from_selection();
self.tabs[i].scene.restore_saved_camera();
// Grid/snap are per-drawing view settings — adopt the opened
// file's active viewport state rather than a global preference.
self.adopt_view_display(i);
self.sync_render_mode_to_active_tile(i);
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.tabs[i].dirty = document_repaired;
self.tabs[i].recovery_save_as_required = document_repaired;
self.tabs[i].history = crate::app::document::HistoryState::default();
self.refresh_properties();
#[cfg(not(target_arch = "wasm32"))]
let interaction_task = {
let wires = self.tabs[i].scene.hit_test_wires();
let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1;
self.prepare_interaction_index_task(i, wires, screen_height)
.unwrap_or_else(Task::none)
};
#[cfg(target_arch = "wasm32")]
let interaction_task = Task::none();
if let Some(opening) = &self.opening {
opening
.state
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
let pending_open_task = if let Some(report) = recovery_report {
self.recovery_report = Some(report);
self.active_modal = Some(crate::app::ModalKind::Recovery);
Task::none()
} else {
#[cfg(not(target_arch = "wasm32"))]
{
// A drawing referencing fonts this machine lacks offers to
// fetch them from the community repository before the user
// studies garbled substitute text (unless recovery already
// owns the modal slot).
let mut missing = crate::io::font_repo::missing_shx_fonts(
&self.tabs[i].scene.document,
);
missing.retain(|name| {
!self
.suppressed_missing_fonts
.contains(&crate::io::font_repo::font_key(name))
});
// Read-only/MCP evaluation sessions must remain non-blocking:
// report missing fonts through control state, but never offer
// a network/download mutation from a read-only launch.
if !missing.is_empty() && self.check_missing_fonts && !self.read_only {
self.font_source_input = self.font_source_url.clone();
self.missing_fonts = Some(missing);
self.missing_fonts_path = self.tabs[i].current_path.clone();
self.missing_fonts_downloading = false;
self.active_modal = Some(crate::app::ModalKind::MissingFonts);
}
}
self.drain_pending_open()
};
let startup_script_task = if !self.pending_startup_script_lines.is_empty() {
let lines = std::mem::take(&mut self.pending_startup_script_lines);
Task::batch(lines.into_iter().map(|l| Task::done(Message::ScriptLine(l))))
} else {
Task::none()
};
Task::batch([thumbs_task, pending_open_task, interaction_task, startup_script_task])
}
pub(super) fn on_wblock_save_result_some(
&mut self,
block_name: String,
path: std::path::PathBuf,
) -> Task<Message> {
let i = self.active_tab;
let document = self.tabs[i].scene.document_for_save();
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
let worker_name = block_name.clone();
let worker_path = path.clone();
background_task(
crate::t!("WBLOCK save"),
move || {
let document = if worker_name == "*" {
crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles)
} else {
crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name)
}
.map_err(|e| e.to_string())?;
crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
},
move |result| Message::WblockWriteFinished(block_name, path, result),
)
}
pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
// Re-build STL bytes (we can't easily pass them through the message).
let i = self.active_tab;
// STL gets the highest-resolution LOD (slot 0) so the
// exported geometry isn't downgraded by the view-dependent
// mesh LOD ladder used for rendering.
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let worker_path = path.clone();
background_task(
crate::t!("STL export"),
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let bytes = crate::io::stl::build_stl(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
write_export(&worker_path, &bytes)
},
move |result| Message::StlExportFinished(path, result),
)
}
pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let i = self.active_tab;
// Export uses LOD 0 (full resolution); see StlExportPath above.
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let worker_path = path.clone();
background_task(
crate::t!("STEP export"),
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let text = crate::io::step::build_step(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
write_export(&worker_path, text.as_bytes())
},
move |result| Message::StepExportFinished(path, result),
)
}
pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let tab_id = self.tabs[self.active_tab].id;
let worker_path = path.clone();
background_task(
crate::t!("OBJ import"),
move || {
let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?;
crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0])
.ok_or_else(|| "no usable geometry in file".to_string())
},
move |result| Message::ObjImportFinished(tab_id, path, result),
)
}
fn sync_view_state_for_save(&mut self, i: usize) {
self.sync_vport_display(i);
if self.tabs[i].active_block_edit.is_none() {
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn prepare_native_save(&mut self, i: usize) {
self.sync_view_state_for_save(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64;
self.sync_solid_models_for_save(i);
self.tabs[i].scene.sync_native_parametric_graph();
}
#[cfg(not(target_arch = "wasm32"))]
fn native_save_verification(
path: &std::path::Path,
mut lease: Option<&mut crate::io::edit_lock::EditLease>,
expected: Option<crate::io::edit_lock::FileFingerprint>,
) -> Result<
(
Option<crate::io::edit_lock::FileFingerprint>,
Option<std::fs::File>,
),
crate::io::SaveFailure,
> {
let expected = match expected {
Some(expected) => Some(expected),
None => {
let captured = match lease.as_deref_mut() {
Some(lease) => match lease.fingerprint() {
Ok(Some(fingerprint)) => Ok(fingerprint),
Ok(None) => crate::io::edit_lock::FileFingerprint::capture(path),
Err(error) => Err(error),
},
None => crate::io::edit_lock::FileFingerprint::capture(path),
};
match captured {
Ok(fingerprint) => Some(fingerprint),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(crate::io::SaveFailure::other(format!(
"could not verify {} before saving: {error}",
path.display()
)));
}
}
}
};
let reader = if expected.is_some() {
match lease.as_deref() {
Some(lease) => lease.reader().map_err(|error| {
crate::io::SaveFailure::other(format!(
"could not verify {} before saving: {error}",
path.display()
))
})?,
None => None,
}
} else {
None
};
Ok((expected, reader))
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task<Message> {
let Some(pending) = self.pending_web_thumbnail_save.take() else {
return Task::none();
};
Task::done(Message::WebSaveScreenshot {
tab_id: pending.tab_id,
filename: pending.filename,
ext: pending.ext,
version: pending.version,
bounds: Some(pending.bounds),
screenshot: crate::sys::capture_canvas(),
})
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task<Message> {
let Some(pending) = self.pending_native_thumbnail_save.take() else {
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == pending.tab_id) else {
self.thumbnail_capture_clean = false;
return Task::none();
};
self.queue_native_save(
i,
pending.path,
pending.version,
pending.purpose,
pending.continuation,
pending.set_current_path,
pending.check_external_change,
)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_screenshot(
&mut self,
tab_id: u64,
filename: String,
ext: String,
version: codec::DxfVersion,
bounds: Option<iced::Rectangle>,
screenshot: Option<iced::window::Screenshot>,
) -> Task<Message> {
self.thumbnail_capture_clean = false;
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
return Task::none();
};
let preview = screenshot.as_ref().and_then(|screenshot| {
bounds.and_then(|bounds| {
crate::io::thumbnail::from_screenshot(
screenshot,
bounds,
version >= codec::DxfVersion::AC1027,
)
})
});
if let Some(preview) = preview {
self.tabs[i].scene.document.preview = Some(preview);
}
let mut recent_task = Task::none();
let document = self.tabs[i].scene.document_for_save();
let saved = match crate::io::save_to_bytes(&document, &ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
self.tabs[i].dirty = false;
self.tabs[i].recovery_save_as_required = false;
recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
true
}
Err(error) => {
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
false
}
};
if self.save_dialog_for_unsaved {
if saved {
if let Some(crate::app::PendingClose::Tab(tab_id)) = self.pending_close.take() {
let continuation = self.update(Message::TabClose(tab_id));
let rest = self.continue_tab_close_queue();
return Task::batch([recent_task, continuation, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([recent_task, retry]);
}
}
recent_task
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn queue_native_save(
&mut self,
i: usize,
path: std::path::PathBuf,
version: codec::DxfVersion,
purpose: crate::app::SavePurpose,
continuation: crate::app::SaveContinuation,
set_current_path: bool,
check_external_change: bool,
) -> Task<Message> {
let tab_id = self.tabs[i].id;
if self
.pending_native_thumbnail_save
.as_ref()
.is_some_and(|pending| pending.tab_id == tab_id)
{
if purpose != crate::app::SavePurpose::Autosave {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
}
return Task::none();
}
let capture_ready =
self.thumbnail_capture_clean && self.pending_native_thumbnail_save.is_none();
if self.active_save_jobs.contains_key(&tab_id) {
if purpose != crate::app::SavePurpose::Autosave {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
}
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::none();
}
let destination_is_current = self.tabs[i]
.current_path
.as_deref()
.is_some_and(|current| native_paths_match(current, &path));
if purpose != crate::app::SavePurpose::Autosave
&& self.tabs[i].recovery_save_as_required
&& destination_is_current
{
self.command_line
.push_error_once(crate::tr!("recovery", "save-new-file-required").as_ref());
self.restore_failed_save_continuation(continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved = continuation != crate::app::SaveContinuation::None;
if capture_ready {
self.thumbnail_capture_clean = false;
}
return self.open_save_dialog_window(i);
}
if purpose != crate::app::SavePurpose::Autosave
&& !set_current_path
&& self.tabs[i].edit_lock_conflict
{
let error = "Drawing edit lock is held by another editor.".to_string();
self.command_line.push_error_once(
crate::tf!(
"Unable to save \"{}\": {error}",
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
)
.as_ref(),
);
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
error,
});
self.restore_failed_save_continuation(continuation, i);
self.active_modal = Some(crate::app::ModalKind::FileInUse);
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::none();
}
if purpose != crate::app::SavePurpose::Autosave
&& i == self.active_tab
&& !self.thumbnail_capture_clean
&& self.main_window.is_some()
&& crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID)
.is_some()
{
self.pending_native_thumbnail_save = Some(crate::app::PendingNativeThumbnailSave {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
check_external_change,
});
self.thumbnail_capture_clean = true;
return Task::none();
}
if set_current_path && !destination_is_current {
match crate::io::edit_lock::EditLease::acquire(&path) {
Ok(lease) => {
self.pending_save_leases.insert(tab_id, lease);
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
self.command_line.push_error_once(
crate::tf!(
"Unable to save \"{}\": {error}",
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
)
.as_ref(),
);
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
error,
});
self.restore_failed_save_continuation(continuation, i);
self.active_modal = Some(crate::app::ModalKind::FileInUse);
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::none();
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.command_line.push_info(
crate::tf!("{error}. External-change checks remain active.").as_ref(),
);
}
}
}
let epoch = self.tabs[i].scene.geometry_epoch;
let revision = self.tabs[i].edit_revision;
let camera_generation = self.tabs[i].scene.camera_generation;
let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab)
.then_some(version >= codec::DxfVersion::AC1027);
let capture_bounds = thumbnail.and_then(|_| {
crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID)
});
let clone_started = iced::time::Instant::now();
let mut snapshot = self.tabs[i].scene.document_for_save();
// First save: references attached with a relative path type while
// the drawing had no file become relative to it.
if self.tabs[i].current_path.is_none() && purpose != crate::app::SavePurpose::Autosave {
crate::io::xref::make_relative(
&mut snapshot,
&self.tabs[i].xref_relative_on_save,
&path,
);
}
// Save-As across folders: rebase relative reference paths onto the
// new base dir inside the snapshot only (live strings are untouched).
if purpose == crate::app::SavePurpose::SaveAs {
let old_base = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
let new_base = path.parent().map(|p| p.to_path_buf());
if let (Some(old), Some(new)) = (old_base, new_base) {
if old != new {
crate::io::xref::rebase_relative_paths_for_save_as(
&mut snapshot,
&old,
&new,
);
}
}
}
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-snapshot {:.1}ms entities={} objects={} purpose={purpose:?}",
clone_ms,
snapshot.entities().count(),
snapshot.objects.len(),
);
}
self.save_job_serial = self.save_job_serial.wrapping_add(1);
let job_id = self.save_job_serial;
self.active_save_jobs.insert(tab_id, job_id);
let previous_autosave =
(purpose != crate::app::SavePurpose::Autosave).then(|| self.autosave_target(i));
let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save;
let verification = if check_external_change && purpose != crate::app::SavePurpose::Autosave
{
let expected = if set_current_path {
None
} else {
self.tabs[i].disk_fingerprint.clone()
};
if self.pending_save_leases.contains_key(&tab_id) {
Self::native_save_verification(
&path,
self.pending_save_leases.get_mut(&tab_id),
expected,
)
} else if destination_is_current {
Self::native_save_verification(&path, self.tabs[i].edit_lease.as_mut(), expected)
} else {
Self::native_save_verification(&path, None, expected)
}
} else {
Ok((None, None))
};
let (expected_fingerprint, verify_reader) = match verification {
Ok(verification) => verification,
Err(error) => {
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::perform(
async move {
crate::app::SaveOutcome {
job_id,
tab_id,
epoch,
revision,
camera_generation,
path,
version,
previous_autosave,
set_current_path,
purpose,
continuation,
refreshed_preview: None,
result: Err(error),
}
},
Message::SaveFinished,
);
}
};
let worker_path = path.clone();
let capture_window = thumbnail.and(capture_bounds).and(self.main_window);
let mut work = Some((
snapshot,
thumbnail,
capture_bounds,
worker_path,
expected_fingerprint,
verify_reader,
path,
previous_autosave,
));
let mut run_save = move |screenshot: Option<iced::window::Screenshot>| {
let (
mut snapshot,
thumbnail,
capture_bounds,
worker_path,
expected_fingerprint,
verify_reader,
path,
previous_autosave,
) = work
.take()
.expect("save capture produced more than one result");
Task::perform(
async move {
let (result, refreshed_preview) = std::thread::spawn(move || {
let mut refreshed_preview = None;
if let Some(png) = thumbnail {
let started = iced::time::Instant::now();
if let Some(preview) = screenshot.as_ref().and_then(|screenshot| {
capture_bounds.and_then(|bounds| {
crate::io::thumbnail::from_screenshot(screenshot, bounds, png)
})
}) {
snapshot.preview = Some(preview);
refreshed_preview = Some(snapshot.preview.clone());
}
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-thumbnail {:.1}ms",
started.elapsed().as_secs_f64() * 1000.0,
);
}
}
let result = crate::io::save_owned_as_version_atomic(
snapshot,
&worker_path,
version,
backup,
expected_fingerprint,
verify_reader,
);
(result, refreshed_preview)
})
.join()
.unwrap_or_else(|_| {
(
Err(crate::io::SaveFailure::other("save worker panicked")),
None,
)
});
crate::app::SaveOutcome {
job_id,
tab_id,
epoch,
revision,
camera_generation,
path,
version,
previous_autosave,
set_current_path,
purpose,
continuation,
refreshed_preview,
result,
}
},
Message::SaveFinished,
)
};
match capture_window {
Some(window) => iced::window::screenshot(window)
.map(Some)
.then(move |screenshot| {
Task::batch([
Task::done(Message::ThumbnailCaptureFinished),
run_save(screenshot),
])
}),
None => {
self.thumbnail_capture_clean = false;
run_save(None)
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_finished(&mut self, outcome: crate::app::SaveOutcome) -> Task<Message> {
let latest = self.active_save_jobs.get(&outcome.tab_id).copied() == Some(outcome.job_id);
if latest {
self.active_save_jobs.remove(&outcome.tab_id);
}
let destination_lease = self.pending_save_leases.remove(&outcome.tab_id);
let Some(i) = self.tabs.iter().position(|tab| tab.id == outcome.tab_id) else {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
};
if !latest {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
}
if let Err(error) = &outcome.result {
if error.externally_modified && outcome.purpose != crate::app::SavePurpose::Autosave {
let file_name = outcome
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| outcome.path.display().to_string());
self.command_line.push_error_once(
crate::tf!("Save stopped: \"{file_name}\" changed outside Open CAD Studio.")
.as_ref(),
);
self.pending_external_change = Some(crate::app::PendingExternalChange {
tab_id: outcome.tab_id,
path: outcome.path.clone(),
version: outcome.version,
purpose: outcome.purpose,
continuation: outcome.continuation,
set_current_path: outcome.set_current_path,
});
self.restore_failed_save_continuation(outcome.continuation, i);
self.active_modal = Some(crate::app::ModalKind::ExternalChange);
return Task::none();
}
if error.file_in_use && outcome.purpose != crate::app::SavePurpose::Autosave {
let file_name = outcome
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| outcome.path.display().to_string());
self.command_line.push_error_once(
crate::tf!(
"Unable to save \"{file_name}\": file is in use by another application."
)
.as_ref(),
);
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id: outcome.tab_id,
path: outcome.path.clone(),
version: outcome.version,
purpose: outcome.purpose,
continuation: outcome.continuation,
set_current_path: outcome.set_current_path,
error: error.to_string(),
});
match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close =
Some(crate::app::PendingClose::Tab(self.tabs[i].id));
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
}
crate::app::SaveContinuation::None => {}
}
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
return match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(self.tabs[i].id));
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::None => Task::none(),
};
}
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == outcome.epoch
&& self.tabs[i].edit_revision == outcome.revision
&& self.tabs[i].scene.camera_generation == outcome.camera_generation;
if snapshot_is_current && outcome.purpose != crate::app::SavePurpose::Autosave {
if let Some(preview) = outcome.refreshed_preview {
self.tabs[i].scene.document.preview = preview;
}
}
let mut tasks = Vec::new();
match outcome.purpose {
crate::app::SavePurpose::Autosave => {
self.command_line
.push_output(crate::t!("Autosaved 1 drawing").as_ref());
}
crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => {
let path_changed = outcome.set_current_path
&& self.tabs[i]
.current_path
.as_deref()
.is_none_or(|current| !native_paths_match(current, &outcome.path));
self.command_line
.push_output(crate::tf!("Saved: {}", outcome.path.display()).as_ref());
if let Some(previous) = outcome.previous_autosave {
if previous != outcome.path {
let _ = std::fs::remove_file(previous);
}
}
if outcome.set_current_path {
let old_path = self.tabs[i].current_path.clone();
if old_path.is_none() {
let pending = std::mem::take(&mut self.tabs[i].xref_relative_on_save);
crate::io::xref::make_relative(
&mut self.tabs[i].scene.document,
&pending,
&outcome.path,
);
}
self.tabs[i].current_path = Some(outcome.path.clone());
self.tabs[i].scene.document.version = outcome.version;
if outcome.purpose == crate::app::SavePurpose::SaveAs {
self.tabs[i].recovery_save_as_required = false;
// Save-As across folders: the save snapshot was rebased
// in `queue_native_save`; rebase the live document too
// so the running session agrees with the file just
// written. Saves are not undoable, so no undo snapshot
// is pushed.
if let Some(old) = old_path.as_deref() {
let old_base = old.parent().map(|p| p.to_path_buf());
let new_base =
outcome.path.parent().map(|p| p.to_path_buf());
if let (Some(old), Some(new)) = (old_base, new_base) {
if old != new {
crate::io::xref::rebase_relative_paths_for_save_as(
&mut self.tabs[i].scene.document,
&old,
&new,
);
}
}
}
}
}
tasks.push(self.push_recent(outcome.path.clone()));
self.refresh_native_edit_guard_after_save(
i,
&outcome.path,
path_changed,
destination_lease,
);
if snapshot_is_current {
self.tabs[i].dirty = false;
}
}
}
match outcome.continuation {
crate::app::SaveContinuation::None => {}
crate::app::SaveContinuation::CloseTab if snapshot_is_current => {
self.pending_close = None;
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.update(Message::TabClose(self.tabs[i].id)));
tasks.push(self.continue_tab_close_queue());
}
crate::app::SaveContinuation::Quit if snapshot_is_current => {
self.pending_close = None;
if self.tabs.iter().any(|tab| tab.dirty) {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
} else {
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.exit_app());
}
}
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(self.tabs[i].id));
tasks.push(self.open_unsaved_dialog_window());
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
}
}
Task::batch(tasks)
}
#[cfg(not(target_arch = "wasm32"))]
fn restore_failed_save_continuation(
&mut self,
continuation: crate::app::SaveContinuation,
tab_idx: usize,
) {
self.pending_close = match continuation {
crate::app::SaveContinuation::None => None,
crate::app::SaveContinuation::CloseTab => self
.tabs
.get(tab_idx)
.map(|tab| crate::app::PendingClose::Tab(tab.id)),
crate::app::SaveContinuation::Quit => Some(crate::app::PendingClose::Quit),
};
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_file_in_use_retry(&mut self) -> Task<Message> {
let Some(mut failure) = self.pending_save_failure.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == failure.tab_id) else {
self.close_active_modal();
return Task::none();
};
if self.tabs[i].edit_lock_conflict {
if let Err(error) = self.retry_native_edit_guard(i, &failure.path) {
let continuation = failure.continuation;
failure.error = error.clone();
self.pending_save_failure = Some(failure);
self.restore_failed_save_continuation(continuation, i);
self.command_line
.push_error_once(crate::tf!("Unable to acquire edit lock: {error}").as_ref());
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
}
self.close_active_modal();
self.restore_failed_save_continuation(failure.continuation, i);
self.active_tab = i;
self.prepare_native_save(i);
self.queue_native_save(
i,
failure.path,
failure.version,
failure.purpose,
failure.continuation,
failure.set_current_path,
true,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_file_in_use_save_as(&mut self) -> Task<Message> {
let Some(failure) = self.pending_save_failure.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == failure.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(failure.continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved = failure.continuation != crate::app::SaveContinuation::None;
self.open_save_dialog_window(i)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_overwrite(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(conflict.continuation, i);
self.active_tab = i;
self.prepare_native_save(i);
self.queue_native_save(
i,
conflict.path,
conflict.version,
conflict.purpose,
conflict.continuation,
conflict.set_current_path,
false,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_save_as(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(conflict.continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved = conflict.continuation != crate::app::SaveContinuation::None;
self.open_save_dialog_window(i)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_reload(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
let metadata = match std::fs::metadata(&conflict.path) {
Ok(metadata) => metadata,
Err(error) => {
self.close_active_modal();
self.command_line
.push_error(crate::tf!("Reload failed: {error}").as_ref());
return Task::none();
}
};
self.close_active_modal();
self.pending_close = None;
self.tab_counter += 1;
self.tabs[i] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = i;
self.apply_display_defaults(i);
self.update(Message::OpenPathPicked(Some((
conflict.path,
metadata.len(),
))))
}
pub(super) fn on_save_file(&mut self) -> Task<Message> {
if self.read_only {
self.command_line.push_error(
crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(),
);
return Task::none();
}
let i = self.active_tab;
// Web serializes immediately below. Native preparation happens
// once after the destination/version is known.
#[cfg(target_arch = "wasm32")]
{
self.sync_view_state_for_save(i);
self.stamp_header_sysvars(i);
}
// Native: save straight to the known path. Web has no path
// (downloads instead), so always go through the Save dialog.
#[cfg(not(target_arch = "wasm32"))]
if !self.tabs[i].recovery_save_as_required {
if let Some(path) = self.tabs[i].current_path.clone() {
// A direct Save preserves the document's current version.
let ver = self.tabs[i].scene.document.version;
self.prepare_native_save(i);
return self.queue_native_save(
i,
path,
ver,
crate::app::SavePurpose::Manual,
crate::app::SaveContinuation::None,
false,
true,
);
}
}
self.save_dialog_for_unsaved = false;
self.save_with_default_format(i)
}
/// Save without the version picker: use the configured default and go straight to
/// the native destination dialog (native) or the browser download (web).
/// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the
/// save-before-close flow — the version picker is reserved for Save As.
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<Message> {
self.active_tab = tab_idx;
self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required {
let document = &self.tabs[tab_idx].scene.document;
let is_dxf =
crate::io::source_is_dxf(self.tabs[tab_idx].current_path.as_deref(), document);
let version = if is_dxf {
document.version
} else {
document.dwg_source_version.unwrap_or(document.version)
};
crate::io::format_for_version(version, is_dxf)
} else {
self.default_save_format.clone()
};
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
self.save_dialog_filename = self.tabs[tab_idx]
.current_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()));
if self.tabs[tab_idx].recovery_save_as_required {
let path = std::path::Path::new(&self.save_dialog_filename);
let stem = path
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
self.save_dialog_filename = format!("{stem}_recovered.{ext}");
}
self.aec_drop_acknowledged = false;
self.on_save_dialog_confirm()
}
pub(super) fn on_save_dialog_confirm(&mut self) -> Task<Message> {
let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
// Warn before a lossy Save-As that would drop unsupported
// (AEC / application) objects kept only as verbatim
// source-version bytes — let the user keep them by saving in the
// source version, or proceed and drop them.
if !self.aec_drop_acknowledged {
let is_dxf = ext.eq_ignore_ascii_case("dxf");
let n = crate::io::dropped_on_save_count(
&self.tabs[self.active_tab].scene.document,
version,
is_dxf,
);
if n > 0 {
self.aec_drop_count = n;
self.active_modal = Some(crate::app::ModalKind::AecDropWarning);
return Task::none();
}
}
// The user need not type an extension: append the selected
// format's one when the entered name carries none.
let name = self.save_dialog_filename.trim();
let filename = if name.is_empty() {
format!("drawing.{ext}")
} else if std::path::Path::new(name).extension().is_none() {
format!("{name}.{ext}")
} else {
name.to_string()
};
self.save_dialog_filename = filename.clone();
let i = self.active_tab;
// Native: hand the destination choice to the OS save dialog —
// it provides folder browsing and overwrite confirmation. The
// chosen version rides in `save_dialog_format` and the write
// happens in `on_save_dialog_path_picked` once a path returns.
#[cfg(not(target_arch = "wasm32"))]
{
let seed_dir = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let default_name = filename;
let (filter_label, filter_ext): (&str, &str) = if ext.eq_ignore_ascii_case("dxf") {
("DXF Files", "dxf")
} else {
("DWG Files", "dwg")
};
let close = self.close_save_dialog_window();
let pick = Task::perform(
async move {
let mut dlg = crate::sys::file_dialog()
.set_title(crate::t!("Save Drawing As").as_ref())
.set_file_name(default_name)
.add_filter(filter_label, &[filter_ext]);
if let Some(dir) = seed_dir {
dlg = dlg.set_directory(dir);
}
dlg.save_file().await.map(|h| crate::sys::handle_path(&h))
},
Message::SaveDialogPathPicked,
);
Task::batch([close, pick])
}
// Web: no filesystem — serialize and hand the browser a download.
#[cfg(target_arch = "wasm32")]
{
let close = self.close_save_dialog_window();
self.sync_view_state_for_save(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.sync_solid_models_for_save(i);
self.tabs[i].scene.sync_native_parametric_graph();
let tab_id = self.tabs[i].id;
let bounds =
crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID);
let Some(bounds) = bounds else {
return Task::batch([
close,
Task::done(Message::WebSaveScreenshot {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds: None,
screenshot: None,
}),
]);
};
self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds,
});
self.thumbnail_capture_clean = true;
close
}
}
/// Native: a destination path came back from the OS save dialog — write the
/// file there in the chosen version. `None` means the user cancelled.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_dialog_path_picked(
&mut self,
picked: Option<std::path::PathBuf>,
) -> Task<Message> {
let Some(path) = picked else {
// Cancelled the OS dialog — if this came from the unsaved-changes →
// save → close flow, re-show the confirmation so the tab isn't lost.
if self.save_dialog_for_unsaved && self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
return Task::none();
};
let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
let i = self.active_tab;
self.prepare_native_save(i);
let continuation = if self.save_dialog_for_unsaved {
match self.pending_close {
Some(crate::app::PendingClose::Tab(_)) => crate::app::SaveContinuation::CloseTab,
Some(crate::app::PendingClose::Quit) => crate::app::SaveContinuation::Quit,
None => crate::app::SaveContinuation::None,
}
} else {
crate::app::SaveContinuation::None
};
self.queue_native_save(
i,
path,
version,
crate::app::SavePurpose::SaveAs,
continuation,
true,
true,
)
}
/// AEC-drop warning → "Save anyway": accept the loss and proceed with the
/// format the user already chose.
pub(super) fn on_aec_drop_proceed(&mut self) -> Task<Message> {
self.aec_drop_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// AEC-drop warning → "Save in source version": switch the target to the
/// document's source type and version, then save.
pub(super) fn on_aec_drop_same_version(&mut self) -> Task<Message> {
let tab = &self.tabs[self.active_tab];
let document = &tab.scene.document;
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), document);
let src = if is_dxf {
document.version
} else {
document.dwg_source_version.unwrap_or(document.version)
};
self.save_dialog_format = crate::io::format_for_version(src, is_dxf);
// Strip the old extension so the confirm path appends the source type.
let stem = std::path::Path::new(&self.save_dialog_filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.save_dialog_filename.clone());
self.save_dialog_filename = stem;
self.aec_drop_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// Where the autosave recovery copy for tab `i` lives.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn autosave_target(&self, i: usize) -> std::path::PathBuf {
match &self.tabs[i].current_path {
Some(p) => {
let name = p
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
p.with_file_name(format!("{name}.ocs-autosave.sv$"))
}
None => {
let safe: String = self.tabs[i]
.tab_display_name()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
std::env::temp_dir().join(format!("OpenCADStudio_{safe}_{}.sv$", self.tabs[i].id))
}
}
}
/// Periodic autosave (SAVETIME): write a `.sv$` recovery copy for every
/// dirty tab — beside the file if it's saved, else under the temp dir — at
/// the document's own DWG version. Best-effort and non-destructive: it never
/// touches the original file or the dirty flag.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
let mut tasks = Vec::new();
for i in 0..self.tabs.len() {
if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) {
continue;
}
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let target = self.autosave_target(i);
tasks.push(self.queue_native_save(
i,
target,
version,
crate::app::SavePurpose::Autosave,
crate::app::SaveContinuation::None,
false,
false,
));
}
Task::batch(tasks)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
Task::none()
}
/// Delete the `.sv$` autosave recovery files for all open drawings. They
/// exist only to survive a crash, so a clean save or exit removes them.
pub(in crate::app) fn cleanup_autosaves(&self) {
#[cfg(not(target_arch = "wasm32"))]
for i in 0..self.tabs.len() {
let _ = std::fs::remove_file(self.autosave_target(i));
}
}
/// Remove the autosave recovery files, then quit the application.
pub(in crate::app) fn exit_app(&self) -> Task<Message> {
self.cleanup_autosaves();
iced::exit()
}
/// The plot scale as `paper : drawing` in the dialog's page-setup unit,
/// with the standard code it names when it names one. The dialog's
/// factor is per the drawing's scale-family unit (inches for an imperial
/// drawing, else millimetres) and already includes the drawing-unit
/// factor; the page setup counts per its own unit. A named ratio is kept
/// as written when no drawing-unit factor hides inside it — a metre
/// drawing plotting "1:100" really plots 10 mm per unit, which is what
/// the file must say — and its standard code is kept even across a
/// paper-unit conversion, the way the commercial application writes
/// "1:1" on an inch page setup over a millimetre paper space as
/// `1 in = 25.4 units`.
fn dialog_plot_scale_ratio(&self) -> ((f64, f64), Option<codec::objects::ScaledType>) {
let d = &self.plot_dialog;
let scene = &self.tabs[self.active_tab].scene;
let factor = plot_dialog_scale_factor(d);
let conversion = scene.scale_family_unit_mm() / d.paper_units.to_mm(1.0);
let named = scene.scale_ratio(&d.scale).filter(|(paper, drawing)| {
(paper / drawing - factor).abs() <= 1e-9 * factor.abs().max(1.0)
});
let standard = named.and_then(|(paper, drawing)| standard_scale_for_ratio(paper, drawing));
let ratio = match named {
Some((paper, drawing)) if (conversion - 1.0).abs() <= 1e-12 => (paper, drawing),
_ => {
let per_unit = (factor * conversion).max(1e-9);
if per_unit >= 1.0 {
(per_unit, 1.0)
} else {
(1.0, 1.0 / per_unit)
}
}
};
(ratio, standard)
}
/// `PAPERUPDATE`: when the printer that just answered does not list the
/// dialog's sheet, either switch to the printer's default sheet (1) or
/// keep the sheet and say so (0).
fn reconcile_sheet_with_printer(&mut self, printer: &str) {
let d = &self.plot_dialog;
let Some(caps) = d.printer_media.as_ref() else {
return;
};
if caps.media.is_empty() {
return;
}
let paper = plot_dialog_paper(d);
if caps.margins_for(&paper).is_some() {
return;
}
let sheet = paper.display();
if d.paper_update == 1 {
let Some(default) = caps
.default_paper
.clone()
.or_else(|| caps.media.first().map(|media| media.paper.clone()))
else {
return;
};
let d = &mut self.plot_dialog;
d.paper = default.canonical.to_string();
(d.paper_width_mm, d.paper_height_mm) = default.sheet_mm(plot_dialog_orientation(d));
let default = default.display();
self.command_line.push_info(
crate::tf!(
"{printer} does not support {sheet}; switched to its default sheet \
{default} (PAPERUPDATE = 1)."
)
.as_ref(),
);
} else {
self.command_line.push_info(
crate::tf!(
"{printer} does not support {sheet}; the sheet is kept (PAPERUPDATE = 0)."
)
.as_ref(),
);
}
}
/// Spell the picked scale in the custom fields.
fn refresh_custom_scale_fields(&mut self) {
let ((paper, drawing), _) = self.dialog_plot_scale_ratio();
let d = &mut self.plot_dialog;
d.custom_scale_paper = trim_decimals(&format!("{paper:.6}"));
d.custom_scale_drawing = trim_decimals(&format!("{drawing:.6}"));
}
/// Make the typed custom ratio the plot scale: the scale list's name for
/// it when it has one, else the ratio itself.
fn adopt_custom_scale(&mut self) {
let d = &self.plot_dialog;
if d.area == "Layout" {
return;
}
let (Ok(paper), Ok(drawing)) = (
d.custom_scale_paper.trim().parse::<f64>(),
d.custom_scale_drawing.trim().parse::<f64>(),
) else {
return;
};
if !(paper > 0.0 && drawing > 0.0 && paper.is_finite() && drawing.is_finite()) {
return;
}
let conversion =
self.tabs[self.active_tab].scene.scale_family_unit_mm() / d.paper_units.to_mm(1.0);
let factor = paper / drawing / conversion;
let d = &mut self.plot_dialog;
d.scale = scale_name_for_factor(&d.scales, factor).unwrap_or_else(|| {
let name = ratio_scale_name(factor);
if !d.scales.iter().any(|(n, _)| n == &name) {
d.scales.push((name.clone(), factor));
d.scales
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
}
name
});
}
/// The plot origin in millimetres from the sheet's corner: the dialog's
/// offsets in the page-setup unit, counted from the printable area's
/// corner unless `PLOTOFFSET` says from the paper edge.
fn dialog_plot_origin_mm(&self) -> (f64, f64) {
let d = &self.plot_dialog;
let x = d.paper_units.to_mm(d.offset_x.trim().parse::<f64>().unwrap_or(0.0));
let y = d.paper_units.to_mm(d.offset_y.trim().parse::<f64>().unwrap_or(0.0));
if d.plot_offset_from_edge {
return (x, y);
}
let ps = self.plot_settings_from_dialog(self.dialog_base_settings());
let m = ps.margins;
let (left, bottom, ..) = crate::scene::rotated_margins(
(m.left, m.bottom, m.right, m.top),
ps.rotation.to_code(),
);
(x + left, y + bottom)
}
/// The settings the dialog edits on top of: the named page setup it was
/// loaded from, else the active layout's own, else a fresh metric sheet.
/// Everything the dialog has no control for (viewport-border and
/// paper-update flags, the plot view handle, the paper unit that fixes
/// the layout's coordinate system) is carried over from here untouched.
fn dialog_base_settings(&self) -> codec::objects::PlotSettings {
use codec::objects::{PlotPaperUnits, PlotSettings};
let scene = &self.tabs[self.active_tab].scene;
self.plot_setup_template
.clone()
.or_else(|| scene.plot_settings_for(&scene.current_layout))
.unwrap_or_else(|| {
let mut settings = PlotSettings::new("");
settings.paper_units = PlotPaperUnits::Millimeters;
settings
})
}
/// The page setup the dialog describes, written the way the file format
/// expects it: the portrait medium with the orientation in `rotation`,
/// standard scales by their code, custom ones as a readable ratio in the
/// layout's own paper unit. Named page setups and the layout's embedded
/// settings both come from here so the two can never drift apart.
fn plot_settings_from_dialog(
&self,
mut ps: codec::objects::PlotSettings,
) -> codec::objects::PlotSettings {
use crate::io::paper_catalog::Orientation;
use codec::objects::{
PlotPaperUnits, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel,
};
let d = &self.plot_dialog;
// Names and margins are decided against the stored settings before
// any field is overwritten.
let paper_size = paper_name_for_settings(d, &ps);
let printer_name = device_name_for_settings(d, &ps);
let margins = margins_for_settings(d, &ps);
let same_medium = paper_unchanged(d, &ps) && ps.paper_width > 0.0 && ps.paper_height > 0.0;
ps.paper_size = paper_size;
ps.printer_name = printer_name;
ps.margins = margins;
// An unchanged sheet keeps the medium exactly as the drawing stores
// it — its driver's precise dimensions in the orientation the driver
// defines it (a PDF driver lists landscape media of its own) — and
// only the rotation says how the sheet is plotted on it. A new
// selection is written as the catalogue's portrait medium.
let natural = if same_medium {
if ps.paper_width > ps.paper_height {
Orientation::Landscape
} else {
Orientation::Portrait
}
} else {
let (width, height) = plot_dialog_paper(d).portrait_mm();
ps.paper_width = width.max(1.0);
ps.paper_height = height.max(1.0);
Orientation::Portrait
};
ps.rotation = plot_dialog_rotation(d, natural);
let layout_area = d.area == "Layout";
ps.plot_type = match d.area.as_str() {
"Window" => PlotType::Window,
"Layout" => PlotType::Layout,
"Display" => PlotType::LastScreenDisplay,
"Limits" => PlotType::Limits,
area if area.starts_with("View: ") => PlotType::View,
_ => PlotType::Extents,
};
ps.plot_view_name = d.area.strip_prefix("View: ").unwrap_or("").to_string();
if d.area == "Window" {
if let Some((x0, y0, x1, y1)) = self.plot_window {
ps.set_plot_window(x0, y0, x1, y1);
}
}
ps.flags.plot_centered = d.center && !layout_area;
// Offsets are typed in the page-setup unit; the file keeps millimetres.
let (origin_x, origin_y) = if layout_area {
(0.0, 0.0)
} else {
(
d.paper_units.to_mm(d.offset_x.trim().parse::<f64>().unwrap_or(0.0)),
d.paper_units.to_mm(d.offset_y.trim().parse::<f64>().unwrap_or(0.0)),
)
};
ps.origin_x = origin_x;
ps.origin_y = origin_y;
ps.paper_units = match d.paper_units {
crate::io::paper_catalog::PaperUnits::Inches => PlotPaperUnits::Inches,
crate::io::paper_catalog::PaperUnits::Millimeters => PlotPaperUnits::Millimeters,
};
if d.fit_to_paper && !layout_area {
ps.set_scale_to_fit();
// The commercial application marks fitted plots with the
// "standard scale" bit and leaves the ratio it computed.
ps.flags.use_standard_scale = true;
} else {
// A layout plot goes through the same path as the other areas:
// the dialog holds it at 1:1, which the unit conversion turns
// into whatever keeps the paper space's unit intact.
let ((numerator, denominator), standard) = self.dialog_plot_scale_ratio();
// The commercial application leaves the "standard scale" bit
// clear even for a standard code and keeps the ratio in the
// numerator/denominator pair; readers that trust the bit would
// otherwise take the stored factor as the ratio itself.
ps.scale_type = standard.unwrap_or(ScaledType::CustomScale);
ps.flags.use_standard_scale = false;
ps.scale_numerator = numerator;
ps.scale_denominator = denominator;
ps.standard_scale_factor =
stored_scale_factor(numerator / denominator, ps.paper_units);
}
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.apply_plot_styles;
ps.flags.show_plot_styles = d.show_plot_styles;
ps.flags.draw_viewports_first = d.paperspace_last;
ps.flags.plot_hidden = d.hide_paperspace;
ps.shade_plot_mode = match d.shade.as_str() {
"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() {
"Low" => ShadePlotResolutionLevel::Draft,
"High" => ShadePlotResolutionLevel::Presentation,
_ => ShadePlotResolutionLevel::Normal,
};
ps.shade_plot_dpi = 300;
ps
}
/// Write the dialog's page setup into the active layout. 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's Set current action.
pub(super) fn apply_plot_page_settings(&mut self) {
let i = self.active_tab;
let layout_name = self.tabs[i].scene.current_layout.clone();
if layout_name == "Model" {
return;
}
let ps = self.plot_settings_from_dialog(self.dialog_base_settings());
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
let plot_area = self.plot_dialog.area.clone();
let center = ps.flags.plot_centered;
let rotation = ps.rotation.to_degrees() as i32;
self.tabs[i]
.scene
.set_layout_plot_settings(&layout_name, &ps);
// Layout limits are the sheet in paper-space units, which the
// settings just written define, placed the way the commercial
// application places it: the printable area's corner is the paper
// space origin, so the sheet starts at minus its displayed margins.
let units_per_mm = self.tabs[i].scene.paper_space_unit_factor().max(1e-9);
let m = ps.margins;
let (left, bottom, ..) = crate::scene::rotated_margins(
(m.left, m.bottom, m.right, m.top),
ps.rotation.to_code(),
);
let (x0, y0) = (-left * units_per_mm, -bottom * units_per_mm);
let (lw, lh) = (w * units_per_mm, h * units_per_mm);
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let codec::objects::ObjectType::Layout(layout) = obj {
if layout.name == layout_name {
layout.min_limits = (x0, y0);
layout.max_limits = (x0 + lw, y0 + lh);
layout.min_extents = (x0, y0, 0.0);
layout.max_extents = (x0 + lw, y0 + lh, 0.0);
break;
}
}
}
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry_no_blocks();
self.command_line.push_info(
crate::tf!(
"Page setup: {w:.1}×{h:.1} mm area={plot_area} \
center={center} rot={rotation}°"
)
.as_ref(),
);
}
pub(in crate::app) fn on_plot_export_path_some(
&mut self,
path: std::path::PathBuf,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model" {
self.plot_dialog.paper_space = true;
self.plot_dialog.scales = self.tabs[i]
.scene
.scale_list()
.into_iter()
.map(|(name, _, factor)| (name, factor))
.collect();
// This load only refreshes paper/scale context from the layout;
// the device the user just plotted with must survive it.
let kept_printer = self.plot_dialog.printer.clone();
let kept_to_file = self.plot_dialog.to_file;
if let Some(settings) = self.tabs[i].scene.effective_plot_settings() {
self.load_plotsettings_into_dialog(&settings);
}
self.plot_dialog.printer = kept_printer;
self.plot_dialog.to_file = kept_to_file;
}
let Some(page) = self.direct_plot_page()
else {
self.command_line.push_error(
crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(),
);
return Task::none();
};
let worker_path = path.clone();
let work = move || {
crate::io::pdf_export::export_pdf(&page, &worker_path)
.map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned())
.map_err(|e| crate::tf!("Export failed: {e}").into_owned())
};
self.run_plot_work(self.plot_dialog.background, false, work)
}
/// Export the pending Extents/Window/Display area using the same clipped
/// render path in model space and paper space.
pub(super) fn on_plot_window_export_path_some(
&mut self,
path: std::path::PathBuf,
) -> Task<Message> {
let job = match self.plot_dialog.area.as_str() {
"Display" => self.display_plot_job(),
"Extents" => self.extents_plot_job(),
_ => self.window_plot_job(),
};
let Some(page) = job
else {
self.command_line
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
return Task::none();
};
let worker_path = path.clone();
self.close_active_modal();
let work = move || {
crate::io::pdf_export::export_pdf(&page, &worker_path)
.map(|_| {
crate::tf!(
"Plotted window to {}",
worker_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
)
.into_owned()
})
.map_err(|e| crate::tf!("Plot failed: {e}").into_owned())
};
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);
}
}
pub(super) fn on_print_all_open(&mut self) -> Task<Message> {
self.print_all_layouts = self.tabs[self.active_tab]
.scene
.layout_names()
.into_iter()
.filter(|name| name != "Model")
.map(|name| (name, true))
.collect();
self.print_all_settings_override = false;
self.active_modal = Some(crate::app::ModalKind::PrintAll);
self.reset_modal_geometry();
Task::none()
}
pub(super) fn on_print_all_options(&mut self) -> Task<Message> {
let previous = self.plot_dialog.clone();
let previous_style = self.active_plot_style.clone();
let previous_window = self.plot_window;
// Once Print All has an explicit override, reopening Options must keep
// those settings instead of replacing them with the active layout's
// page setup.
let keep_print_all_override = self.print_all_settings_override;
// Refresh runtime data such as printers, paper sizes, plot styles and
// named page setups.
let task = self.on_plot_dialog_open();
if keep_print_all_override {
// Restore only the user-editable plot settings. Runtime lists populated
// above remain intact.
self.plot_dialog.copy_settings_from(&previous);
self.active_plot_style = previous_style.clone();
self.plot_window = previous_window;
}
self.print_all_options_prev = Some(previous);
self.print_all_plot_style_prev = Some(previous_style);
self.print_all_plot_window_prev = Some(previous_window);
self.print_all_options = true;
self.plot_dialog.paper_space = true;
self.plot_dialog.area = "Layout".into();
task
}
fn print_all_pages(&mut self) -> Result<Vec<crate::io::pdf_export::PdfPageInput>, String> {
let available = self.tabs[self.active_tab].scene.layout_names();
let selected: Vec<String> = self
.print_all_layouts
.iter()
.filter(|(name, checked)| *checked && available.contains(name))
.map(|(name, _)| name.clone())
.collect();
if selected.is_empty() {
return Err(crate::t!("Select at least one layout.").into_owned());
}
let i = self.active_tab;
let original_layout = self.tabs[i].scene.current_layout.clone();
let original_viewport = self.tabs[i].scene.active_viewport;
let original_psltscale = self.tabs[i]
.scene
.document
.header
.paper_space_linetype_scaling;
let original_plimcheck = self.tabs[i].scene.document.header.paper_space_limit_check;
let original_dialog = self.plot_dialog.clone();
let original_style = self.active_plot_style.clone();
let original_window = self.plot_window;
let original_setup = self.plot_setup_template.clone();
let original_camera = self.tabs[i].scene.camera.borrow().clone();
let original_camera_generation = self.tabs[i].scene.camera_generation;
let override_dialog = self
.print_all_settings_override
.then(|| original_dialog.clone());
let override_style = self
.print_all_settings_override
.then(|| original_style.clone());
let result = (|| {
let mut pages = Vec::with_capacity(selected.len());
for name in selected {
let page_setup = self.tabs[i]
.scene
.plot_settings_for(&name)
.ok_or_else(|| crate::tf!("Layout '{name}' has no page setup.").into_owned())?;
{
let scene = &mut self.tabs[i].scene;
scene.current_layout = name.clone();
scene.active_viewport = None;
scene.load_current_layout_state();
}
if let Some(dialog) = &override_dialog {
self.plot_dialog = dialog.clone();
self.active_plot_style = override_style.clone().flatten();
self.plot_window = original_window;
} else {
self.plot_dialog.paper_space = true;
self.plot_window = None;
self.plot_dialog.window = None;
self.load_plotsettings_into_dialog(&page_setup);
}
let dialog = self.plot_dialog.clone();
if dialog.area == "Display" {
self.tabs[i].scene.restore_saved_camera();
}
if dialog.style_missing && dialog.apply_plot_styles {
return Err(crate::tf!(
"Layout '{name}' plot style table '{}' is not loaded.",
dialog.style_name
)
.into_owned());
}
let area_page = match dialog.area.as_str() {
"Display" => self.display_plot_job(),
"Extents" => self.extents_plot_job(),
"Limits" => self.limits_plot_job(),
"Window" => self.window_plot_job(),
area if area.starts_with("View: ") => {
self.named_view_plot_job(area.trim_start_matches("View: "))
}
_ => None,
};
let page = if dialog.area == "Layout" {
self.layout_plot_page_for("Layout")
} else {
area_page.ok_or_else(|| {
crate::tf!("Layout '{name}' plot area is empty.").into_owned()
})?
};
pages.push(page);
}
Ok(pages)
})();
self.tabs[i].scene.current_layout = original_layout;
self.tabs[i].scene.active_viewport = original_viewport;
self.tabs[i]
.scene
.document
.header
.paper_space_linetype_scaling = original_psltscale;
self.tabs[i].scene.document.header.paper_space_limit_check = original_plimcheck;
self.plot_dialog = original_dialog;
self.active_plot_style = original_style;
self.plot_window = original_window;
self.plot_setup_template = original_setup;
*self.tabs[i].scene.camera.borrow_mut() = original_camera;
self.tabs[i].scene.camera_generation = original_camera_generation;
result
}
pub(super) fn on_print_all_pdf_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let dialog = self.plot_dialog.clone();
if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles {
self.command_line.push_error(
crate::tf!("Plot style table '{}' is not loaded.", dialog.style_name).as_ref(),
);
return Task::none();
}
let pages = match self.print_all_pages() {
Ok(pages) => pages,
Err(error) => {
self.command_line.push_error(&error);
return Task::none();
}
};
let worker_path = path.clone();
let fallback_style = self.dialog_plot_style(&dialog);
self.save_config();
self.close_active_modal();
let work = move || {
crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, fallback_style.as_ref())
.map(|_| {
crate::tf!(
"Exported {} layouts to {}",
pages.len(),
worker_path.display()
)
.into_owned()
})
.map_err(|error| crate::tf!("Export failed: {error}").into_owned())
};
self.run_print_all_work(dialog.background_publish, work)
}
pub(super) fn on_print_all_print(&mut self) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
{
self.command_line
.push_error(crate::t!("Printing is not available in the web version.").as_ref());
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
{
let dialog = self.plot_dialog.clone();
if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles
{
self.command_line.push_error(
crate::tf!("Plot style table '{}' is not loaded.", dialog.style_name).as_ref(),
);
return Task::none();
}
let pages = match self.print_all_pages() {
Ok(pages) => pages,
Err(error) => {
self.command_line.push_error(&error);
return Task::none();
}
};
let options = self.plot_print_options(&dialog);
let fallback_style = self.dialog_plot_style(&dialog);
let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all");
self.save_config();
self.close_active_modal();
self.command_line
.push_info(crate::t!("Sending selected layouts to the system printer…").as_ref());
let work = move || {
crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, fallback_style.as_ref())
.and_then(|_| {
crate::io::print_to_printer::print_existing_pdf(&temp_path, &options)
})
.map(|printer| {
crate::tf!("Sent {} layouts to printer: {printer}", pages.len())
.into_owned()
})
.map_err(|error| crate::tf!("Print failed: {error}").into_owned())
};
self.run_print_all_work(true, work)
}
}
fn run_print_all_work<F>(&mut self, background: bool, work: F) -> Task<Message>
where
F: FnOnce() -> Result<String, String> + Send + 'static,
{
if background {
background_task(crate::t!("Print all"), work, Message::PrintAllFinished)
} else {
Task::done(Message::PrintAllFinished(work()))
}
}
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(crate::t!("Plot"), work, move |result| {
Message::BackgroundIoFinished(result, reopen_plot)
})
} else {
let result = work();
self.report_plot_result(result, reopen_plot);
Task::none()
}
}
/// Assemble a layout page for PDF export, preview, or printing.
pub(super) fn layout_plot_page(&self) -> PdfPageInput {
self.layout_plot_page_for(&self.plot_dialog.area)
}
pub(in crate::app) fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let paper_space = scene.current_layout != "Model";
let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog);
let mut content = 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.
if paper_space {
content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())]
.iter()
.filter(|wire| wire.name != "paper_printable_area")
.count();
std::sync::Arc::make_mut(&mut content.wires)
.retain(|wire| wire.name != "paper_printable_area");
}
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
// Paper geometry is stored in layout units; PDF pages use mm.
// Scale both geometry and offsets by the same unit conversion.
// The old path converted only page width/height, so inch/scaled
// layouts placed tiny or 25.4×-shifted content on the page.
let units_per_mm = scene.paper_space_unit_factor().max(1e-9);
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);
// 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 = plot_area == "Extents";
let plot_layout = plot_area == "Layout";
// 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, origin_y) = self.dialog_plot_origin_mm();
let (scale, offset_x, offset_y) = if plot_extents {
let (min_x, min_y, max_x, max_y) =
plot_content_extents(&content).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.fit_to_paper {
const MARGIN: f64 = 1.05;
((paper_w / MARGIN) / content_w)
.min((paper_h / MARGIN) / content_h)
.max(1e-9)
} else {
(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
} else {
origin_x
};
let target_y = if centered {
(paper_h - content_h * scale) * 0.5
} else {
origin_y
};
(scale, target_x / scale - min_x, target_y / scale - min_y)
} else if plot_layout {
// Map the complete paper-space sheet to the physical paper
// selected in the dialog. Layout bounds can be stored in mm,
// inches, or carry incomplete legacy metadata; deriving the
// scale from the visible sheet avoids applying a stale unit
// factor twice while preserving loaded layouts exactly when
// their metadata is valid.
let bounds_w = (x1 - x0).max(1e-9);
let bounds_h = (y1 - y0).max(1e-9);
let scale = (selected_sheet.0 / bounds_w)
.min(selected_sheet.1 / bounds_h)
.max(1e-9);
let target_x = (selected_sheet.0 - bounds_w * scale) * 0.5;
let target_y = (selected_sheet.1 - bounds_h * scale) * 0.5;
(scale, target_x / scale - x0, target_y / scale - y0)
} else {
// Legacy direct callers still get dialog positioning. Normal
// Display/Window paths use `area_plot_job` instead.
let scale = mm_per_unit;
let target_x = if centered {
(paper_w - (x1 - x0) * scale) * 0.5
} else {
origin_x
};
let target_y = if centered {
(paper_h - (y1 - y0) * scale) * 0.5
} else {
origin_y
};
(scale, target_x / scale - x0, target_y / scale - y0)
};
let (base_page_w, base_page_h) = if plot_layout {
selected_sheet
} else {
(paper_w, paper_h)
};
let (page_w, page_h) = match rotation {
90 | 270 => (base_page_h, base_page_w),
_ => (base_page_w, base_page_h),
};
// Layout plots keep the full physical sheet as the PDF page, but
// ink is restricted to the device's printable rectangle. Extents
// has its own fitted area and must not inherit this paper clip.
let clip = if plot_layout {
scene
.printable_area_limits()
.map(|((px0, py0), (px1, py1))| {
(
(px0 + offset_x) as f32,
(py0 + offset_y) as f32,
(px1 - px0) as f32,
(py1 - py0) as f32,
)
})
} else {
None
};
return PdfPageInput {
content,
paper_w: page_w,
paper_h: page_h,
offset_x,
offset_y,
rotation_deg: rotation,
scale: scale as f32,
clip,
options: Self::pdf_plot_options(&self.plot_dialog),
plot_style: self.dialog_plot_style(&self.plot_dialog),
};
}
// Model space keeps its established extents + 5% margin behaviour.
let (page_w, page_h, offset_x, offset_y) =
if let Some((min, max)) = scene.model_space_extents() {
let margin = 1.05_f64;
let width = ((max.x - min.x) as f64 * margin).max(1.0);
let height = ((max.y - min.y) as f64 * margin).max(1.0);
let pad_x = (width - (max.x - min.x) as f64) * 0.5;
let pad_y = (height - (max.y - min.y) as f64) * 0.5;
(
width,
height,
-(min.x as f64) + pad_x,
-(min.y as f64) + pad_y,
)
} else {
(297.0, 210.0, 0.0, 0.0)
};
PdfPageInput {
content,
paper_w: page_w,
paper_h: page_h,
offset_x,
offset_y,
rotation_deg: 0,
scale: 1.0,
clip: None,
options: Self::pdf_plot_options(&self.plot_dialog),
plot_style: self.dialog_plot_style(&self.plot_dialog),
}
}
/// Build direct PDF/printer output using the physical layout sheet in
/// paper space and the selected ISO sheet/scale around Model extents.
fn direct_plot_page(&self) -> Option<PdfPageInput> {
if self.tabs[self.active_tab].scene.current_layout != "Model" {
return Some(self.layout_plot_page_for("Layout"));
}
let page = self.extents_plot_job()?;
let content = &page.content;
if content.wires.is_empty() && content.hatches.is_empty()
&& content.wipeouts.is_empty() && content.images.is_empty()
{
return None;
}
Some(page)
}
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
let Some(page) = self.direct_plot_page()
else {
self.command_line.push_error(
crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(),
);
return Task::none();
};
let options = self.plot_print_options(&self.plot_dialog);
self.command_line
.push_info(crate::t!("Sending to system printer…").as_ref());
background_task(
crate::t!("Print to printer"),
move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires_with(page, options),
)
},
Message::PrintResult,
)
}
/// QUICKPRINT / QP — use the current selection's bounding box as the plot
/// window and export a PDF (drawing folder + name + timestamp) with the
/// active page setup, no dialog. Model space only. (#325)
pub(crate) fn on_quick_print_handles(
&mut self,
handles: Vec<codec::Handle>,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model" {
self.command_line
.push_error(crate::t!("Quick print works in model space.").as_ref());
return Task::none();
}
let set: std::collections::HashSet<codec::Handle> = handles.into_iter().collect();
// Union the AABBs of the picked entities' wires (world XY), matched by
// each wire's handle.
let (x0, y0, x1, y1, any) = {
let scene = &self.tabs[i].scene;
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = f32::NEG_INFINITY;
let mut y1 = f32::NEG_INFINITY;
let mut any = false;
for w in scene.entity_wires().iter() {
let picked = crate::scene::Scene::handle_from_wire_name(&w.name)
.is_some_and(|h| set.contains(&h));
if !picked {
continue;
}
let [ax0, ay0, ax1, ay1] = w.aabb;
if ax0.is_finite() && ay0.is_finite() && ax1.is_finite() && ay1.is_finite() {
x0 = x0.min(ax0);
y0 = y0.min(ay0);
x1 = x1.max(ax1);
y1 = y1.max(ay1);
any = true;
}
}
(x0, y0, x1, y1, any)
};
if !any {
self.command_line
.push_error(crate::t!("Selection has no printable geometry.").as_ref());
return Task::none();
}
if !(x1 > x0 && y1 > y0) {
self.command_line
.push_error(crate::t!("Selection has no printable area.").as_ref());
return Task::none();
}
// Small margin so the outermost strokes aren't clipped flush to the edge.
let mx = ((x1 - x0) * 0.02).max(0.0);
let my = ((y1 - y0) * 0.02).max(0.0);
self.plot_window = Some((
(x0 - mx) as f64,
(y0 - my) as f64,
(x1 + mx) as f64,
(y1 + my) as f64,
));
let path = self.quick_print_path();
self.on_plot_window_export_path_some(path)
}
/// Auto output path for quick print: the drawing's folder + name + a
/// timestamp, falling back to the temp dir / "drawing" when unsaved.
fn quick_print_path(&self) -> std::path::PathBuf {
let i = self.active_tab;
let cur = self.tabs[i].current_path.as_deref();
let dir = cur
.and_then(|p| p.parent())
.map(|d| d.to_path_buf())
.unwrap_or_else(std::env::temp_dir);
let stem = cur
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
dir.join(format!("{stem}_{ts}.pdf"))
}
/// Open the full Plot / Print dialog, seeding its state from the active
/// 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_catalog::Orientation;
let previous = self.plot_dialog.clone();
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());
let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model";
let plot_views = self.tabs[self.active_tab]
.scene
.document
.views
.iter()
.filter(|view| view.paper_space == paper_space)
.map(|view| view.name.clone())
.collect();
// Keep session-only choices while loading drawing fields from the layout.
let d = &mut self.plot_dialog;
match crate::io::print_to_printer::list_printers() {
Ok(printers) => {
d.printers = printers;
d.printers_error = None;
}
Err(error) => {
d.printers = Vec::new();
d.printers_error = Some(error);
}
}
d.default_printer = crate::io::plot_device::default_printer_name();
d.plot_styles = crate::io::plot_style::available_ctb_names();
d.scales = scales;
d.plot_views = plot_views;
if d.scale.eq_ignore_ascii_case("fit") {
d.fit_to_paper = true;
d.scale_lw = false;
d.scale = one_to_one.clone();
} else if !d.scales.iter().any(|(name, _)| name == &d.scale) {
d.scale = one_to_one.clone();
}
d.paper_space = paper_space;
d.paper = self.plot_paper.canonical.to_string();
(d.paper_width_mm, d.paper_height_mm) = self.plot_paper.sheet_mm(self.plot_orientation);
d.orientation = match self.plot_orientation {
Orientation::Portrait => "Portrait",
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;
self.plot_dialog.name_input = None;
self.plot_dialog.name_rename = false;
if self.plot_dialog.fit_to_paper {
self.plot_dialog.scale_lw = false;
}
self.refresh_page_setups();
if let Some(error) = self.plot_dialog.printers_error.clone() {
self.command_line
.push_warning(crate::tf!("Could not list printers: {error}").as_ref());
}
self.plot_prev = Some(previous);
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
let layout_entry = format!("*{cur}*");
// The auto-applied page setup must not reset the output device: a
// drawing rarely stores one, so honouring it would snap the dialog
// back to the system default on every open. The user's last choice
// (persisted across sessions with the rest of the dialog) survives
// unless the setup names an explicit device of its own.
let kept_printer = self.plot_dialog.printer.clone();
let kept_to_file = self.plot_dialog.to_file;
if self.tabs[self.active_tab]
.scene
.plot_settings_for(&cur)
.is_some()
{
self.select_page_setup(&layout_entry);
} else {
self.select_page_setup(crate::ui::window::plot::SETUP_PREV);
}
let setup_named_a_device =
self.plot_dialog.printer.is_some() || self.plot_dialog.to_file;
if !setup_named_a_device {
self.plot_dialog.printer = kept_printer;
self.plot_dialog.to_file = kept_to_file;
}
// A persisted plot window (saved with the dialog settings) restores
// across sessions; a freshly loaded one wins so the two stay in sync.
if self.plot_window.is_none() {
self.plot_window = self.plot_dialog.window;
} else {
self.plot_dialog.window = self.plot_window;
}
if self.plot_dialog.scale.eq_ignore_ascii_case("fit") {
self.plot_dialog.fit_to_paper = true;
self.plot_dialog.scale = one_to_one.clone();
} else if !self
.plot_dialog
.scales
.iter()
.any(|(name, _)| name == &self.plot_dialog.scale)
{
self.plot_dialog.scale = one_to_one;
}
if self.plot_dialog.fit_to_paper {
self.plot_dialog.scale_lw = false;
}
// A model-space plot cannot use the paper-only Layout area.
if cur == "Model" {
if self.plot_dialog.area == "Layout" {
self.plot_dialog.area = "Window".into();
self.normalize_common_plot_dialog();
}
}
self.active_modal = Some(crate::app::ModalKind::Plot);
self.request_printer_media()
}
/// Keep the dialog's view of the selected printer's media current: use the
/// session cache when the printer was asked before, otherwise ask it in
/// the background and clear the stale answer meanwhile. PDF output and
/// the default printer list the catalogue instead.
fn request_printer_media(&mut self) -> Task<Message> {
let d = &mut self.plot_dialog;
let Some(printer) = (!d.to_file).then(|| d.printer.clone()).flatten() else {
d.printer_media = None;
return Task::none();
};
if let Some(caps) = crate::io::plot_device::cached_printer_capabilities(&printer) {
d.printer_media = Some(caps);
return Task::none();
}
d.printer_media = None;
let name = printer.clone();
background_task(
crate::t!("Printer capabilities"),
move || crate::io::plot_device::printer_capabilities(&printer),
move |caps| Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(name, caps)),
)
}
/// The inline custom-sheet editor of the Plot dialog. A sheet is added
/// to (or replaced in) the persisted list under its media name and
/// selected at once; the list is saved immediately so it survives a
/// crash the way a plotter configuration would.
fn on_custom_paper_msg(&mut self, msg: crate::ui::window::plot::CustomPaperMsg) {
use crate::ui::window::plot::{CustomPaperDraft, CustomPaperMsg as C, MarginSide};
let d = &mut self.plot_dialog;
match msg {
C::Open => {
let paper = plot_dialog_paper(d);
let existing = d
.custom_papers
.iter()
.find(|custom| custom.canonical() == paper.canonical);
let margins = match existing {
Some(custom) => custom.margins_mm(),
None => plot_dialog_device(d).margins_mm(&paper, &d.custom_papers),
};
let mut draft = CustomPaperDraft::from_paper(&paper, margins);
// A catalogue sheet is only the starting size; the new sheet
// gets its own name (the source application's generic one when left blank).
if existing.is_none() {
draft.name.clear();
}
d.custom_editor = Some(draft);
}
C::Cancel => d.custom_editor = None,
C::Name(value) => Self::edit_custom_draft(d, |draft| draft.name = value),
C::Width(value) => Self::edit_custom_draft(d, |draft| draft.width = value),
C::Height(value) => Self::edit_custom_draft(d, |draft| draft.height = value),
C::Units(value) => Self::edit_custom_draft(d, |draft| draft.inches = value == "Inches"),
C::Margin(side, value) => Self::edit_custom_draft(d, |draft| {
let index = match side {
MarginSide::Left => 0,
MarginSide::Bottom => 1,
MarginSide::Right => 2,
MarginSide::Top => 3,
};
draft.margins[index] = value;
}),
C::Add => {
let Some(draft) = d.custom_editor.as_mut() else {
return;
};
match draft.build() {
Ok(custom) => {
let canonical = custom.canonical();
d.custom_papers.retain(|existing| existing.canonical() != canonical);
d.custom_papers.push(custom);
d.custom_editor = None;
d.paper = canonical;
let (w, h) = plot_dialog_sheet_mm(d);
d.paper_width_mm = w;
d.paper_height_mm = h;
self.save_config();
}
Err(error) => draft.error = Some(error),
}
}
C::Remove => {
let selected = plot_dialog_paper(d).canonical.into_owned();
let before = d.custom_papers.len();
d.custom_papers.retain(|custom| custom.canonical() != selected);
if d.custom_papers.len() != before {
self.save_config();
}
}
}
}
fn edit_custom_draft(
d: &mut crate::ui::window::plot::PlotDialogState,
edit: impl FnOnce(&mut crate::ui::window::plot::CustomPaperDraft),
) {
if let Some(draft) = d.custom_editor.as_mut() {
edit(draft);
draft.error = None;
}
}
/// Properties… for the selected printer.
///
/// On CUPS platforms (Linux, macOS) the driver's options are listed in an
/// in-line editor and applied to every job for that printer through
/// `lp -o`. On Windows the system opens the driver's own printing
/// preferences in its own process, and the print job honours what the
/// user saves there. Without a named printer, or where the options
/// cannot be listed, the platform's printer settings open instead.
fn on_printer_properties(&mut self) -> Task<Message> {
// "Default" is a real printer: its preferences sheet is the one to
// open, not the system's printer list.
let chosen = self
.plot_dialog
.printer
.clone()
.or_else(|| self.plot_dialog.default_printer.clone());
let Some(printer) = chosen.filter(|_| !self.plot_dialog.to_file) else {
self.open_printer_settings(None);
return Task::none();
};
#[cfg(target_os = "windows")]
{
// The driver's sheet is modal: it pumps messages itself until the
// user closes it. Hosted on our event-loop thread it starves the
// loop it is sharing, so the window stops painting and a core
// spins. The system's own preferences process pumps its own
// messages instead, which leaves ours free.
self.open_printer_settings(Some(&printer));
return Task::none();
}
#[allow(unreachable_code)]
{
self.plot_dialog.printer_editor = Some(crate::ui::window::plot::PrinterOptionsDraft {
printer: printer.clone(),
options: None,
choices: Default::default(),
error: None,
});
let name = printer.clone();
background_task(
crate::t!("Printer options"),
move || crate::io::print_to_printer::printer_options(&printer),
move |result| {
Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded(
name, result,
))
},
)
}
}
/// The platform's printer settings surface: the driver's printing
/// preferences for `printer`, or — without one — a settings panel on
/// Linux, the printers control panel on Windows, System Settings on macOS.
fn open_printer_settings(&mut self, printer: Option<&str>) {
match crate::io::print_to_printer::open_printer_properties(printer) {
Ok(()) => self
.command_line
.push_info(crate::t!("Opened printer properties.").as_ref()),
Err(error) => self.command_line.push_error(&error),
}
}
fn normalize_common_plot_dialog(&mut self) {
self.plot_dialog.center = true;
self.plot_dialog.offset_x = "0.0".into();
self.plot_dialog.offset_y = "0.0".into();
self.plot_dialog.fit_to_paper = true;
self.plot_dialog.scale_lw = false;
}
/// Handle one edit / action from the Plot dialog.
pub(super) fn on_plot_dlg(
&mut self,
msg: crate::ui::window::plot::PlotDlgMsg,
) -> Task<Message> {
use crate::ui::window::plot::{
PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, STYLE_NONE,
};
match msg {
M::Close => {
self.close_active_modal();
Task::none()
}
M::Printer(s) => {
// Driver choices belong to a printer; the editor closes when
// the target changes.
self.plot_dialog.printer_editor = None;
if s == OUT_PDF {
self.plot_dialog.to_file = true;
} else if s == OUT_DEFAULT {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = None;
} else {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = Some(s);
}
self.save_config();
self.request_printer_media()
}
M::PrinterMedia(printer, caps) => {
let d = &mut self.plot_dialog;
if !d.to_file && d.printer.as_deref() == Some(printer.as_str()) {
d.printer_media = caps;
self.reconcile_sheet_with_printer(&printer);
}
Task::none()
}
M::CustomPaper(msg) => {
self.on_custom_paper_msg(msg);
Task::none()
}
M::PrinterProperties => self.on_printer_properties(),
M::Import(msg) => self.on_page_setup_import(msg),
M::PrinterOptionsLoaded(printer, result) => {
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
if draft.printer == printer {
match result {
Ok(options) => {
// Start from the remembered choices, then the
// driver's defaults.
let remembered = self
.plot_dialog
.driver_options
.get(&printer)
.cloned()
.unwrap_or_default();
let draft = self.plot_dialog.printer_editor.as_mut().unwrap();
draft.choices = options
.iter()
.map(|option| {
let choice = remembered
.get(&option.key)
.filter(|c| option.choices.iter().any(|x| &x.keyword == *c))
.cloned()
.unwrap_or_else(|| option.default.clone());
(option.key.clone(), choice)
})
.collect();
draft.options = Some(options);
}
Err(error) => draft.error = Some(error),
}
}
}
Task::none()
}
M::PrinterOptionSet(key, choice) => {
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
draft.choices.insert(key, choice);
}
Task::none()
}
M::PrinterOptionsReset => {
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
if let Some(options) = &draft.options {
draft.choices = options
.iter()
.map(|option| (option.key.clone(), option.default.clone()))
.collect();
}
}
Task::none()
}
M::PrinterOptionsApply => {
if let Some(draft) = self.plot_dialog.printer_editor.take() {
let overrides = draft.overrides();
if overrides.is_empty() {
self.plot_dialog.driver_options.remove(&draft.printer);
} else {
self.plot_dialog.driver_options.insert(draft.printer.clone(), overrides);
}
self.save_config();
}
Task::none()
}
M::PrinterOptionsCancel => {
self.plot_dialog.printer_editor = None;
Task::none()
}
M::Paper(s) => {
self.plot_dialog.paper = s;
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
self.plot_dialog.paper_width_mm = w;
self.plot_dialog.paper_height_mm = h;
Task::none()
}
M::Orientation(s) => {
self.plot_dialog.orientation = s;
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
self.plot_dialog.paper_width_mm = w;
self.plot_dialog.paper_height_mm = h;
Task::none()
}
M::Area(s) => {
if self.print_all_options && s != "Layout" {
return Task::none();
}
// A paper Layout page setup commonly carries physical-sheet
// 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.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()
}
M::Scale(s) => {
if self.plot_dialog.area != "Layout" {
self.plot_dialog.scale = s;
self.refresh_custom_scale_fields();
}
Task::none()
}
M::PaperUnits(s) => {
use crate::io::paper_catalog::PaperUnits;
let units = if s == "Inches" {
PaperUnits::Inches
} else {
PaperUnits::Millimeters
};
let d = &mut self.plot_dialog;
if units != d.paper_units {
// The offsets are physical distances: re-spell them in the
// new unit. The scale converts with the unit on its own.
let convert = |text: &str| {
text.parse::<f64>()
.map(|value| units.from_mm(d.paper_units.to_mm(value)))
.map(|value| trim_decimals(&format!("{value:.6}")))
.unwrap_or_else(|_| text.to_string())
};
d.offset_x = convert(&d.offset_x);
d.offset_y = convert(&d.offset_y);
d.paper_units = units;
self.refresh_custom_scale_fields();
}
Task::none()
}
M::CustomScalePaper(s) => {
self.plot_dialog.custom_scale_paper = s;
self.adopt_custom_scale();
Task::none()
}
M::CustomScaleDrawing(s) => {
self.plot_dialog.custom_scale_drawing = s;
self.adopt_custom_scale();
Task::none()
}
M::Quality(s) => {
self.plot_dialog.quality = s;
Task::none()
}
M::Shade(s) => {
self.plot_dialog.shade = s;
Task::none()
}
M::Copies(s) => {
self.plot_dialog.copies = s;
Task::none()
}
M::OffsetX(s) => {
self.plot_dialog.offset_x = s;
Task::none()
}
M::OffsetY(s) => {
self.plot_dialog.offset_y = 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;
if d.fit_to_paper {
d.scale_lw = false;
}
}
PlotFlag::Center if d.area != "Layout" => d.center = !d.center,
PlotFlag::ScaleLw if !d.fit_to_paper => d.scale_lw = !d.scale_lw,
PlotFlag::PlotStyles if !d.style_name.is_empty() => {
d.apply_plot_styles = !d.apply_plot_styles
}
PlotFlag::DisplayStyles if d.paper_space && !d.style_name.is_empty() => {
d.show_plot_styles = !d.show_plot_styles
}
PlotFlag::UpsideDown => {
d.upside_down = !d.upside_down;
}
PlotFlag::Lineweights => d.lineweights = !d.lineweights,
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::HidePaperspace => d.hide_paperspace = !d.hide_paperspace,
PlotFlag::SaveToLayout => d.save_to_layout = !d.save_to_layout,
_ => {}
}
Task::none()
}
M::LoadStyle => Task::done(Message::PlotStyleLoad),
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.apply_plot_styles = false;
self.plot_dialog.show_plot_styles = false;
self.plot_dialog.style_missing = false;
self.plot_dialog.style_error = None;
} else {
match crate::io::plot_style::PlotStyleTable::load_named(&name) {
Ok(table) => {
self.plot_dialog.style_name = table.name.clone();
self.plot_dialog.apply_plot_styles = true;
self.plot_dialog.style_missing = false;
self.plot_dialog.style_error = None;
self.report_plot_style_warnings(&table);
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);
self.plot_dialog.style_error = Some(error);
}
}
}
Task::none()
}
M::PickWindow => {
if self.print_all_options {
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model"
&& self.tabs[i].scene.active_viewport.is_some()
{
// A layout Window is selected in paper coordinates and may
// extend anywhere on the canvas, including outside the sheet.
// Leave MSPACE so the floating viewport cannot constrain the
// two picks to its model view.
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.active_viewport = None;
self.adopt_view_display(i);
self.tabs[i].refresh_active_ucs();
self.refresh_properties();
self.sync_dyn_fields();
}
self.close_active_modal();
Task::done(Message::Command("PLOTWINDOW".into()))
}
M::SelectSetup(name) => {
self.select_page_setup(&name);
if self.print_all_options {
self.plot_dialog.paper_space = true;
self.plot_dialog.area = "Layout".into();
}
// The setup may name a different printer.
self.request_printer_media()
}
M::SetCurrent => {
self.apply_dialog_to_layout();
self.command_line
.push_info(crate::t!("Page setup applied to the layout.").as_ref());
Task::none()
}
M::NewSetup => {
if self.print_all_options {
return Task::none();
}
// Create a setup from the current editor values, then start an
// inline rename so the user can name it.
let name = self.next_page_setup_name("Setup");
let ps = self.dialog_to_plotsettings();
self.tabs[self.active_tab].scene.page_setup_save(&name, ps);
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup = name.clone();
self.refresh_page_setups();
self.plot_dialog.name_input = Some(name);
self.plot_dialog.name_rename = true;
Task::none()
}
M::CopySetup => {
if self.print_all_options {
return Task::none();
}
// Duplicate the selected entry — a layout OR a named setup —
// into a new standalone named page setup.
let sel = self.plot_dialog.selected_setup.clone();
let scene = &self.tabs[self.active_tab].scene;
let (src_ps, base) = if is_layout_entry(&sel) {
let ln = layout_entry_name(&sel);
(scene.plot_settings_for(ln), format!("{ln} copy"))
} else {
(scene.page_setup_get(&sel), format!("{sel} copy"))
};
if let Some(ps) = src_ps {
let name = self.next_page_setup_name(&base);
self.tabs[self.active_tab].scene.page_setup_save(&name, ps);
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup = name.clone();
self.refresh_page_setups();
self.plot_dialog.name_input = Some(name);
self.plot_dialog.name_rename = true;
}
Task::none()
}
M::RenameStart(name) => {
if self.print_all_options {
return Task::none();
}
// Only standalone named setups can be renamed.
if is_layout_entry(&name) || is_special_entry(&name) {
return Task::none();
}
self.plot_dialog.selected_setup = name.clone();
if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(&name) {
self.load_plotsettings_into_dialog(&ps);
}
self.plot_dialog.name_input = Some(name);
self.plot_dialog.name_rename = true;
Task::none()
}
M::DeleteSetup => {
if self.print_all_options {
return Task::none();
}
let sel = self.plot_dialog.selected_setup.clone();
if !sel.is_empty() && !is_layout_entry(&sel) {
self.tabs[self.active_tab].scene.page_setup_delete(&sel);
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup.clear();
self.refresh_page_setups();
}
Task::none()
}
M::NameInput(s) => {
self.plot_dialog.name_input = Some(s);
Task::none()
}
M::NameCommit => {
if let Some(name) = self.plot_dialog.name_input.take() {
let name = name.trim().to_string();
if !name.is_empty() {
if self.plot_dialog.name_rename {
let old = self.plot_dialog.selected_setup.clone();
self.tabs[self.active_tab]
.scene
.page_setup_rename(&old, &name);
} else {
let ps = self.dialog_to_plotsettings();
self.tabs[self.active_tab].scene.page_setup_save(&name, ps);
}
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup = name;
self.refresh_page_setups();
}
}
self.plot_dialog.name_rename = false;
Task::none()
}
M::NameCancel => {
self.plot_dialog.name_input = None;
self.plot_dialog.name_rename = false;
Task::none()
}
M::Preview => self.on_plot_dlg_commit(true),
M::Commit if self.print_all_options => {
if self.plot_dialog.style_missing && self.plot_dialog.apply_plot_styles {
self.command_line.push_error(
crate::tf!(
"Plot style table '{}' is not loaded.",
self.plot_dialog.style_name
)
.as_ref(),
);
return Task::none();
}
self.plot_dialog.paper_space = true;
self.plot_dialog.area = "Layout".into();
self.sync_dialog_plot_runtime();
self.save_config();
self.print_all_settings_override = true;
self.print_all_options = false;
self.print_all_options_prev = None;
self.print_all_plot_style_prev = None;
self.print_all_plot_setup_prev = None;
if let Some(previous) = self.print_all_plot_window_prev.take() {
self.plot_window = previous;
}
self.active_modal = Some(crate::app::ModalKind::PrintAll);
self.reset_modal_geometry();
Task::none()
}
M::Commit => self.on_plot_dlg_commit(false),
}
}
pub(super) fn refresh_page_setups(&mut self) {
use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV};
let scene = &self.tabs[self.active_tab].scene;
// <none> / <previous>, then layouts (`*name*`), then named setups.
let mut list = vec![SETUP_NONE.to_string(), SETUP_PREV.to_string()];
list.extend(scene.layout_names().into_iter().map(|n| format!("*{n}*")));
list.extend(scene.page_setup_names());
self.plot_dialog.page_setups = list;
}
/// Apply a page-setup list selection to the editor. Handles the pseudo
/// entries (`<none>` / `<previous>`), layout rows (`*name*`) and named
/// setups.
pub(super) fn select_page_setup(&mut self, name: &str) {
use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV};
self.plot_dialog.selected_setup = name.to_string();
if name == SETUP_NONE {
self.plot_setup_template = None;
// No page setup: default geometry + PDF output.
let is_model = self.tabs[self.active_tab].scene.current_layout == "Model";
let d = &mut self.plot_dialog;
d.to_file = true;
let a4 = crate::io::paper_catalog::default_paper();
d.paper = a4.canonical.to_string();
d.orientation = "Landscape".into();
(d.paper_width_mm, d.paper_height_mm) =
a4.sheet_mm(crate::io::paper_catalog::Orientation::Landscape);
d.area = if is_model {
"Window".into()
} else {
"Layout".into()
};
d.center = true;
d.offset_x = "0.0".into();
d.offset_y = "0.0".into();
d.paper_units = crate::io::paper_catalog::PaperUnits::Millimeters;
d.upside_down = false;
d.hide_paperspace = false;
d.fit_to_paper = is_model;
d.scale_lw = false;
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());
self.refresh_custom_scale_fields();
} else if name == SETUP_PREV {
if let Some(prev) = self.plot_prev.clone() {
self.plot_dialog.copy_settings_from(&prev);
}
} else if is_layout_entry(name) {
if let Some(ps) = self.tabs[self.active_tab]
.scene
.plot_settings_for(layout_entry_name(name))
{
self.load_plotsettings_into_dialog(&ps);
}
} else if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(name) {
self.load_plotsettings_into_dialog(&ps);
}
}
/// A page-setup name based on `base` that isn't already taken (`base`,
/// `base 2`, `base 3`, …).
fn next_page_setup_name(&self, base: &str) -> String {
let existing = self.tabs[self.active_tab].scene.page_setup_names();
if !existing.iter().any(|n| n == base) {
return base.to_string();
}
(2..)
.map(|i| format!("{base} {i}"))
.find(|c| !existing.iter().any(|n| n == c))
.unwrap_or_else(|| base.to_string())
}
/// Build a `PlotSettings` from the current dialog fields (for saving a named
/// page setup).
/// The dialog's values as a named page setup.
fn dialog_to_plotsettings(&self) -> codec::objects::PlotSettings {
self.plot_settings_from_dialog(self.dialog_base_settings())
}
/// Load a `PlotSettings` into the dialog editor fields.
pub(in crate::app) fn load_plotsettings_into_dialog(&mut self, ps: &codec::objects::PlotSettings) {
use crate::io::paper_catalog::PaperUnits;
use codec::objects::{PlotType, ShadePlotMode, ShadePlotResolutionLevel};
self.plot_setup_template = Some(ps.clone());
if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() {
self.plot_window = Some((
ps.plot_window.lower_left_x,
ps.plot_window.lower_left_y,
ps.plot_window.upper_right_x,
ps.plot_window.upper_right_y,
));
self.plot_dialog.window = self.plot_window;
}
let mut style_error = None;
if !ps.current_style_sheet.is_empty()
&& self
.active_plot_style
.as_ref()
.is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet))
{
match crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) {
Ok(table) => {
self.report_plot_style_warnings(&table);
self.active_plot_style = Some(table);
}
Err(error) => style_error = Some(error),
}
}
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() {
String::new()
} 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));
// The stored media name wins (it is what the source application wrote); a drawing
// without a usable name is matched by size, and an unknown size keeps
// its own dimensions as a custom sheet. The sheet's orientation is the
// stored width/height order; the rotation below may flip it again.
let paper = crate::io::paper_catalog::from_drawing(
&ps.paper_size,
ps.paper_width,
ps.paper_height,
);
let orient = if ps.paper_width >= ps.paper_height {
"Landscape"
} else {
"Portrait"
}
.to_string();
let family_unit_mm = self.tabs[self.active_tab].scene.scale_family_unit_mm();
let d = &mut self.plot_dialog;
d.paper = paper.canonical.to_string();
d.paper_width_mm = ps.paper_width.max(1.0);
d.paper_height_mm = ps.paper_height.max(1.0);
d.orientation = orient;
d.area = match ps.plot_type {
PlotType::Window => "Window".to_string(),
PlotType::Layout => "Layout".to_string(),
PlotType::LastScreenDisplay => "Display".to_string(),
PlotType::Limits => "Limits".to_string(),
PlotType::View if !ps.plot_view_name.is_empty() => {
d.plot_views.push(ps.plot_view_name.clone());
d.plot_views.sort();
d.plot_views.dedup();
format!("View: {}", ps.plot_view_name)
}
_ => "Extents".to_string(),
};
if !d.paper_space && d.area == "Layout" {
d.area = "Extents".into();
}
d.center = ps.flags.plot_centered;
d.paper_units = match ps.paper_units {
codec::objects::PlotPaperUnits::Inches => PaperUnits::Inches,
_ => PaperUnits::Millimeters,
};
d.offset_x = trim_decimals(&format!("{:.4}", d.paper_units.from_mm(ps.origin_x)));
d.offset_y = trim_decimals(&format!("{:.4}", d.paper_units.from_mm(ps.origin_y)));
d.hide_paperspace = ps.flags.plot_hidden;
let deg = ps.rotation.to_degrees() as i32;
if matches!(deg, 90 | 270) {
d.orientation = if d.orientation == "Portrait" {
"Landscape"
} else {
"Portrait"
}
.into();
}
d.upside_down = matches!(deg, 180 | 270);
d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit();
let ratio = ps.scale_numerator / ps.scale_denominator;
let stored_factor = if ratio.is_finite() && ratio > 0.0 {
ratio
} else if ps.flags.use_standard_scale
&& ps.standard_scale_factor.is_finite()
&& ps.standard_scale_factor > 0.0
{
ratio_from_stored_scale_factor(ps.standard_scale_factor, ps.paper_units)
} else {
ps.scale_type.scale_factor()
};
// The file's factor is per the layout's paper unit; the picker's are
// per the drawing's scale-family unit (see `plot_settings_from_dialog`).
let target_factor = if stored_factor.is_finite() && stored_factor > 0.0 {
stored_factor * plot_paper_unit_mm(ps.paper_units) / family_unit_mm
} else {
1.0
};
if !d.fit_to_paper {
d.scale = scale_name_for_factor(&d.scales, target_factor).unwrap_or_else(|| {
// A scale the drawing has no name for stays what it is rather
// than snapping to 1:1; the ratio entry disappears again with
// the next scale-list refresh once it is no longer selected.
let name = ratio_scale_name(target_factor);
d.scales.push((name.clone(), target_factor));
d.scales
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
name
});
} else if !d.scales.iter().any(|(name, _)| name == &d.scale) {
// A fitted plot's stored ratio is what the fit computed, not a
// choice; the picker just needs a valid entry behind it.
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());
}
d.scale_lw = ps.flags.scale_lineweights && !d.fit_to_paper;
d.lineweights = ps.flags.print_lineweights;
d.paperspace_last = ps.flags.draw_viewports_first;
d.shade = if ps.flags.plot_hidden {
"Hidden Line"
} else {
match ps.shade_plot_mode {
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 => "Low",
ShadePlotResolutionLevel::Presentation
| ShadePlotResolutionLevel::Maximum
| ShadePlotResolutionLevel::Custom => "High",
_ => "Normal",
}
.into();
match crate::io::plot_device::PlotDevice::from_stored_name(&ps.printer_name).0 {
crate::io::plot_device::PlotDevice::Pdf => {
d.to_file = true;
d.printer = None;
}
crate::io::plot_device::PlotDevice::Printer(name) => {
d.to_file = false;
d.printer = Some(name);
}
crate::io::plot_device::PlotDevice::None => {
d.to_file = false;
d.printer = None;
}
}
d.style_name = style_name;
d.apply_plot_styles = ps.flags.plot_plot_styles;
d.show_plot_styles = ps.flags.show_plot_styles;
d.style_missing = !d.style_name.is_empty() && !style_loaded;
d.style_error = if d.style_missing { style_error } else { None };
self.refresh_custom_scale_fields();
}
/// Tell the user what a plot style table's loader had to tolerate.
pub(in crate::app) fn report_plot_style_warnings(
&mut self,
table: &crate::io::plot_style::PlotStyleTable,
) {
for warning in &table.load_warnings {
let name = &table.name;
self.command_line.push_warning(&format!("{name}: {warning}"));
}
}
/// Copy transient paper/scale choices out of the dialog without changing
/// the active layout.
fn sync_dialog_plot_runtime(&mut self) {
let d = &self.plot_dialog;
self.plot_paper = plot_dialog_paper(d);
self.plot_orientation = plot_dialog_orientation(d);
}
fn apply_dialog_to_layout(&mut self) {
self.sync_dialog_plot_runtime();
self.apply_plot_page_settings();
}
/// Open a preview PDF, export a PDF, or send the job to the chosen printer.
fn on_plot_dlg_commit(&mut self, preview: bool) -> Task<Message> {
let d = self.plot_dialog.clone();
if d.style_missing && d.apply_plot_styles {
self.command_line.push_error(
crate::tf!("Plot style table '{}' is not loaded.", d.style_name).as_ref(),
);
return Task::none();
}
// Remember the user's print preferences across sessions.
self.save_config();
// A preview never touches the layout; a sent plot writes the dialog
// into it when "Save changes to layout" is on, so the page setup
// remembers the last plot the way other applications expect. The
// runtime paper/scale choices drive this one plot operation either way.
if !preview && d.save_to_layout && d.paper_space {
self.apply_dialog_to_layout();
} else {
self.sync_dialog_plot_runtime();
}
self.active_modal = None;
self.reset_modal_geometry();
// Extents, Window and Display use one plot path in both spaces. Only
// Paper-space Layout is special: it uses the physical sheet bounds.
if d.area != "Layout" {
let job = match d.area.as_str() {
"Display" => self.display_plot_job(),
"Extents" => self.extents_plot_job(),
"Limits" => self.limits_plot_job(),
"Window" => self.window_plot_job(),
area if area.starts_with("View: ") => {
self.named_view_plot_job(area.trim_start_matches("View: "))
}
_ => None,
};
let Some(page) = job
else {
self.command_line
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
};
// Preview renders to a temp PDF and opens it — never a save dialog,
// whatever the output target is.
if preview {
let tmp = crate::io::print_to_printer::temp_pdf_path("preview");
let work = move || {
crate::io::pdf_export::export_pdf(&page, &tmp)
.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| crate::t!("Opened plot preview.").into_owned())
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
};
return self.run_plot_work(d.background, true, work);
}
if d.to_file {
// Tested clipped export (opens a save dialog).
return Task::done(Message::PlotWindowExport);
}
let tmp = crate::io::print_to_printer::temp_pdf_path("print");
let opts = self.plot_print_options(&d);
let work = move || {
crate::io::pdf_export::export_pdf(&page, &tmp)
.and_then(|_| {
crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)
})
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
.map_err(|e| crate::tf!("Print failed: {e}").into_owned())
};
return self.run_plot_work(true, false, work);
}
let page = self.layout_plot_page();
if preview {
let tmp = crate::io::print_to_printer::temp_pdf_path("preview");
let work = move || {
crate::io::pdf_export::export_pdf(&page, &tmp)
.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| crate::t!("Opened plot preview.").into_owned())
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
};
return self.run_plot_work(d.background, true, work);
}
if d.to_file {
// Reuse the tested PDF export flow (it opens a save dialog).
return Task::done(Message::PlotExport);
}
let opts = self.plot_print_options(&d);
self.command_line
.push_info(crate::t!("Sending to system printer…").as_ref());
let work = move || {
iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with(
page, opts,
))
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
.map_err(|error| crate::tf!("Print failed: {error}").into_owned())
};
self.run_plot_work(true, false, work)
}
/// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from
/// the dialog state.
fn plot_print_options(
&self,
d: &crate::ui::window::plot::PlotDialogState,
) -> crate::io::print_to_printer::PrintOptions {
crate::io::print_to_printer::PrintOptions {
printer: d.printer.clone(),
copies: d.copies.trim().parse::<u32>().unwrap_or(1).max(1),
quality: Some(d.quality.clone()),
driver_options: d
.printer
.as_ref()
.and_then(|printer| d.driver_options.get(printer))
.map(|options| options.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default(),
}
}
fn pdf_plot_options(
d: &crate::ui::window::plot::PlotDialogState,
) -> crate::io::pdf_export::PdfPlotOptions {
crate::io::pdf_export::PdfPlotOptions {
object_lineweights: d.lineweights,
scale_lineweights: d.scale_lw && !d.fit_to_paper,
// PLOTTRANSPARENCYOVERRIDE: 0 never, 2 always, 1 as the dialog says.
transparency: match d.transparency_override {
0 => false,
2 => true,
_ => d.transparency,
},
stamp: d.stamp,
merge_lines: d.merge_lines,
}
}
fn dialog_plot_style(
&self,
d: &crate::ui::window::plot::PlotDialogState,
) -> Option<crate::io::plot_style::PlotStyleTable> {
if d.style_name.is_empty() || d.style_missing || !d.apply_plot_styles {
return None;
}
self.active_plot_style
.as_ref()
.filter(|table| table.name.eq_ignore_ascii_case(&d.style_name))
.cloned()
}
pub(in crate::app) fn window_plot_job(&self) -> Option<PdfPageInput> {
self.area_plot_job(self.plot_window?)
}
pub(in crate::app) fn display_plot_job(&self) -> Option<PdfPageInput> {
self.area_plot_job(self.display_plot_window()?)
}
pub(in crate::app) fn limits_plot_job(&self) -> Option<PdfPageInput> {
let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?;
self.area_plot_job((min.x, min.y, max.x, max.y))
}
fn named_view_plot_job(&self, name: &str) -> Option<PdfPageInput> {
let view = self.tabs[self.active_tab]
.scene
.document
.views
.iter()
.find(|view| {
view.name.eq_ignore_ascii_case(name)
&& view.paper_space
== (self.tabs[self.active_tab].scene.current_layout != "Model")
})?;
let half_w = view.width.abs() * 0.5;
let half_h = view.height.abs() * 0.5;
(half_w > 1e-9 && half_h > 1e-9).then_some(())?;
self.area_plot_job((
view.center.x - half_w,
view.center.y - half_h,
view.center.x + half_w,
view.center.y + half_h,
))
}
pub(in crate::app) fn extents_plot_job(&self) -> Option<PdfPageInput> {
let scene = &self.tabs[self.active_tab].scene;
if scene.current_layout == "Model" {
let (min, max) = scene.model_space_extents()?;
return self.area_plot_job((min.x as f64, min.y as f64, max.x as f64, max.y as f64));
}
let content = plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
let extents = plot_content_extents(&content)?;
self.area_plot_job(extents)
}
/// Current visible rectangle in the active space. The result is deliberately
/// not clamped to the paper sheet: Display and Window may include the grey
/// canvas outside the sheet, matching Model-space plotting.
pub(in crate::app) fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> {
let scene = &self.tabs[self.active_tab].scene;
let (canvas_w, canvas_h) = scene.selection.borrow().vp_size;
let viewport = if scene.current_layout == "Model" {
scene.active_model_tile_bounds(canvas_w, canvas_h)
} else {
iced::Rectangle {
x: 0.0,
y: 0.0,
width: canvas_w,
height: canvas_h,
}
};
if viewport.width < 1.0 || viewport.height < 1.0 {
return None;
}
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: viewport.width,
height: viewport.height,
};
let camera = scene.camera.borrow();
let mut x0 = f64::INFINITY;
let mut y0 = f64::INFINITY;
let mut x1 = f64::NEG_INFINITY;
let mut y1 = f64::NEG_INFINITY;
for point in [
iced::Point::new(0.0, 0.0),
iced::Point::new(bounds.width, 0.0),
iced::Point::new(bounds.width, bounds.height),
iced::Point::new(0.0, bounds.height),
] {
let world = camera.unproject_on_plane(point, bounds, glam::Vec3::Z, glam::DVec3::ZERO);
x0 = x0.min(world.x);
y0 = y0.min(world.y);
x1 = x1.max(world.x);
y1 = y1.max(world.y);
}
(x0.is_finite()
&& y0.is_finite()
&& x1.is_finite()
&& y1.is_finite()
&& x1 - x0 > 1e-6
&& y1 - y0 > 1e-6)
.then_some((x0, y0, x1, y1))
}
/// Render a selected rectangle through one shared Model/Paper path. The
/// window may lie partly or wholly outside a paper sheet.
fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option<PdfPageInput> {
use crate::io::paper_catalog::{window_to_sheet, PlotScale};
let i = self.active_tab;
let (x0, y0, x1, y1) = window;
if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 {
return None;
}
let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&self.plot_dialog);
let win_w = (x1 - x0).max(1e-9);
let win_h = (y1 - y0).max(1e-9);
let scale_sel = if self.plot_dialog.fit_to_paper {
PlotScale::Fit
} else {
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(factor * mm_per_unit)
} else {
PlotScale::Fit
}
};
let (scale, centered_x, centered_y) =
window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel);
let (origin_x, origin_y) = self.dialog_plot_origin_mm();
let target_x = if self.plot_dialog.center {
centered_x
} else {
origin_x
};
let target_y = if self.plot_dialog.center {
centered_y
} else {
origin_y
};
let scene = &self.tabs[i].scene;
let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32);
let mut content = plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
let in_window = |wire: &crate::io::pdf_export::PlotWire| {
wire.name != "paper_printable_area"
&& wire.aabb[0] <= wx1 && wire.aabb[2] >= wx0
&& wire.aabb[1] <= wy1 && wire.aabb[3] >= wy0
};
content.group_splits.wires = content.wires
[..content.group_splits.wires.min(content.wires.len())]
.iter()
.filter(|wire| in_window(wire))
.count();
std::sync::Arc::make_mut(&mut content.wires).retain(in_window);
let offset_x = (target_x / scale) - x0;
let offset_y = (target_y / scale) - y0;
let clip = Some((
(target_x / scale) as f32,
(target_y / scale) as f32,
win_w as f32,
win_h as f32,
));
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),
};
Some(PdfPageInput {
content,
paper_w: page_w,
paper_h: page_h,
offset_x,
offset_y,
rotation_deg: rotation,
scale: scale as f32,
clip,
options: Self::pdf_plot_options(&self.plot_dialog),
plot_style: self.dialog_plot_style(&self.plot_dialog),
})
}
pub(super) fn on_plot_style_panel_apply(&mut self) -> Task<Message> {
let aci = self.plotstyle_panel_aci as usize;
if let Some(table) = self.active_plot_style.as_mut() {
if let Some(entry) = table.aci_entries.get_mut(aci) {
// Parse color.
let color_str = self.ps_color_buf.trim();
if color_str.is_empty() {
entry.color = None;
} else if color_str.starts_with('#') && color_str.len() == 7 {
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
entry.color = Some([r, g, b]);
}
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
entry.lineweight = lw;
}
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
entry.screening = sc.min(100);
}
}
} else {
// No table loaded: create an identity table and apply.
let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb");
if let Some(entry) = table.aci_entries.get_mut(aci) {
let color_str = self.ps_color_buf.trim();
if color_str.starts_with('#') && color_str.len() == 7 {
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
entry.color = Some([r, g, b]);
}
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
entry.lineweight = lw;
}
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
entry.screening = sc.min(100);
}
}
self.active_plot_style = Some(table);
self.command_line
.push_output(crate::tf!("Created new CTB table, ACI {aci} updated.").as_ref());
}
Task::none()
}
pub(super) fn on_plot_style_panel_save(&mut self) -> Task<Message> {
if self.active_plot_style.is_none() {
self.command_line.push_error(
crate::t!("No plot style table loaded. Load or create one first.").as_ref(),
);
return Task::none();
}
let default_name = self
.active_plot_style
.as_ref()
.map(|t| t.name.clone())
.unwrap_or("export.ctb".into());
Task::perform(
async move {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!("Save Plot Style Table").as_ref())
.set_file_name(&default_name)
.add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"])
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
#[cfg(not(target_arch = "wasm32"))]
let dialog = match crate::io::plot_style::ensure_plot_styles_dir() {
Ok(dir) => dialog.set_directory(dir),
Err(_) => dialog,
};
dialog
.save_file()
.await
.map(|h| crate::sys::handle_path(&h))
},
Message::PlotStylePanelSavePath,
)
}
}
#[cfg(test)]
mod plot_paper_tests {
use crate::app::{Message, OpenCADStudio};
use crate::ui::window::plot::PlotDlgMsg;
/// A drawing on `Layout1` whose page setup names its sheet the way a
/// Windows printer driver does — a bare `A4` with landscape dimensions.
fn app_with_printer_named_sheet() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
let i = app.active_tab;
// Millimetre drawing units, so a ratio plots as written; the metre
// case has its own test.
app.tabs[i].scene.document.header.insertion_units = 4;
let mut ps = app.tabs[i]
.scene
.plot_settings_for("Layout1")
.expect("a fresh layout carries plot settings");
ps.paper_size = "A4".into();
ps.paper_width = 297.0;
ps.paper_height = 210.0;
// Such drivers spell the orientation in the dimensions, not the rotation.
ps.rotation = codec::objects::PlotRotation::None;
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
app
}
fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) {
let ps = layout_settings(app);
(ps.paper_size, ps.paper_width, ps.paper_height)
}
fn layout_settings(app: &OpenCADStudio) -> codec::objects::PlotSettings {
app.tabs[app.active_tab]
.scene
.plot_settings_for("Layout1")
.unwrap()
}
fn layout_rotation(app: &OpenCADStudio) -> codec::objects::PlotRotation {
layout_settings(app).rotation
}
/// Store the drawing's plot settings for `Layout1` after `edit` touched them.
fn edit_layout_settings(
app: &mut OpenCADStudio,
edit: impl FnOnce(&mut codec::objects::PlotSettings),
) {
let i = app.active_tab;
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
edit(&mut ps);
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
}
#[test]
fn a_new_drawing_starts_with_a_compatible_default_layout() {
use codec::objects::{PlotRotation, PlotType, ScaledType};
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
let ps = layout_settings(&app);
assert_eq!(ps.paper_size, "ISO_A4_(210.00_x_297.00_MM)");
assert_eq!((ps.paper_width, ps.paper_height), (210.0, 297.0));
assert_eq!(ps.rotation, PlotRotation::Degrees90);
assert_eq!(ps.printer_name, "none_device");
assert_eq!(ps.plot_type, PlotType::Layout);
assert_eq!(ps.scale_type, ScaledType::OneToOne);
assert!(ps.flags.use_standard_scale && ps.flags.print_lineweights);
// The sheet on screen is the landscape A4 the rotation describes.
let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
assert_eq!((x1 - x0, y1 - y0), (297.0, 210.0));
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.orientation, "Landscape");
assert!(!app.plot_dialog.upside_down);
}
#[test]
fn orientation_is_stored_as_a_rotation_of_the_medium() {
use codec::objects::PlotRotation as R;
use crate::ui::window::plot::PlotFlag;
// A newly picked sheet is the catalogue's portrait medium…
let portrait_medium = [
("Portrait", false, R::None),
("Landscape", false, R::Degrees90),
("Portrait", true, R::Degrees180),
("Landscape", true, R::Degrees270),
];
// …while the drawing's own landscape `A4` medium is kept, so the
// same choices rotate relative to it.
let landscape_medium = [
("Landscape", false, R::None),
("Portrait", false, R::Degrees90),
("Landscape", true, R::Degrees180),
("Portrait", true, R::Degrees270),
];
for (pick_a3, cases) in [(true, portrait_medium), (false, landscape_medium)] {
for (orientation, upside_down, rotation) in cases {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
if pick_a3 {
let a3 = "ISO_A3_(297.00_x_420.00_MM)";
let _ = app.on_plot_dlg(PlotDlgMsg::Paper(a3.into()));
}
let _ = app.on_plot_dlg(PlotDlgMsg::Orientation(orientation.into()));
if upside_down {
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::UpsideDown));
}
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
let medium = if pick_a3 { (297.0, 420.0) } else { (297.0, 210.0) };
assert_eq!((ps.paper_width, ps.paper_height), medium, "{orientation}");
assert_eq!(ps.rotation, rotation, "{orientation} upside-down={upside_down}");
// The next dialog reads the same choices back out of the rotation.
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.orientation, orientation);
assert_eq!(app.plot_dialog.upside_down, upside_down);
let sheet = super::plot_dialog_sheet_mm(&app.plot_dialog);
let long_side = medium.0.max(medium.1);
assert_eq!(sheet.0 > sheet.1, orientation == "Landscape", "{sheet:?}");
assert_eq!(sheet.0.max(sheet.1), long_side);
}
}
}
#[test]
fn standard_scales_are_written_by_code_and_others_as_ratios() {
use codec::objects::ScaledType;
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
assert!(!app.plot_dialog.fit_to_paper);
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::OneToHundred);
assert!(
!ps.flags.use_standard_scale,
"the bit stays clear, as the source application writes it"
);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 100.0));
// The stored factor follows the millimetre convention: ratio × 25.4.
assert!((ps.standard_scale_factor - 0.254).abs() < 1e-12);
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.scale, "1:100");
// A ratio the format has no code for is a custom scale, spelled as the ratio.
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:3".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::CustomScale);
assert!(!ps.flags.use_standard_scale);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0));
// …and survives a reopen even though the drawing's scale list lacks it.
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.scale, "1:3");
assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 1.0 / 3.0).abs() < 1e-12);
// Fit to paper is its own scale type and carries the bit; a Layout
// plot goes back to the 1:1 the dialog holds it at.
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::ScaleToFit);
assert!(ps.flags.use_standard_scale);
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::OneToOne);
assert!(!ps.flags.use_standard_scale);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 1.0));
assert!((ps.standard_scale_factor - 25.4).abs() < 1e-12);
}
#[test]
fn a_metre_drawing_stores_the_millimetres_it_really_plots_per_unit() {
use codec::objects::ScaledType;
let mut app = app_with_printer_named_sheet();
// Insertion units: metres. "1:100" then plots 10 mm per drawing unit.
app.tabs[app.active_tab].scene.document.header.insertion_units = 6;
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 10.0).abs() < 1e-9);
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::CustomScale, "not the format's 1:100");
assert_eq!((ps.scale_numerator, ps.scale_denominator), (10.0, 1.0));
assert!((ps.standard_scale_factor - 254.0).abs() < 1e-9);
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.scale, "1:100");
}
#[test]
fn an_inch_layout_keeps_its_unit_and_converts_the_scale() {
use codec::objects::{PlotPaperUnits, ScaledType};
let mut app = app_with_printer_named_sheet();
edit_layout_settings(&mut app, |ps| ps.paper_units = PlotPaperUnits::Inches);
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(
ps.paper_units,
PlotPaperUnits::Inches,
"the layout's unit is not ours to change"
);
// 1 mm = 100 units is 1 inch = 2540 units on an inch page setup; the
// standard code still names the choice, as the source application does.
assert_eq!(ps.scale_type, ScaledType::OneToHundred);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0));
assert!((ps.standard_scale_factor - 1.0 / 2540.0).abs() < 1e-15);
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.scale, "1:100");
// A layout plot keeps the paper space in millimetres: "1:1" is
// `1 in = 25.4 units`.
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::OneToOne);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4));
assert!((ps.standard_scale_factor - 1.0 / 25.4).abs() < 1e-15);
}
#[test]
fn a_named_setup_and_the_layout_settings_are_written_alike() {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:50".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::UpsideDown));
let named = app.dialog_to_plotsettings();
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let layout = layout_settings(&app);
assert_eq!(layout.paper_size, named.paper_size);
assert_eq!(layout.printer_name, named.printer_name);
assert_eq!(layout.margins, named.margins);
assert_eq!(
(layout.paper_width, layout.paper_height),
(named.paper_width, named.paper_height)
);
assert_eq!(layout.rotation, named.rotation);
assert_eq!(layout.plot_type, named.plot_type);
assert_eq!((layout.origin_x, layout.origin_y), (named.origin_x, named.origin_y));
assert_eq!(layout.scale_type, named.scale_type);
assert_eq!(
(layout.scale_numerator, layout.scale_denominator),
(named.scale_numerator, named.scale_denominator)
);
assert_eq!(layout.standard_scale_factor, named.standard_scale_factor);
assert_eq!(layout.flags, named.flags);
assert_eq!(layout.shade_plot_mode, named.shade_plot_mode);
assert_eq!(layout.shade_plot_resolution, named.shade_plot_resolution);
assert_eq!(named.rotation, codec::objects::PlotRotation::Degrees270);
assert_eq!(named.scale_type, codec::objects::ScaledType::OneToFifty);
}
#[test]
fn flags_the_dialog_does_not_edit_survive_a_round_trip() {
let mut app = app_with_printer_named_sheet();
edit_layout_settings(&mut app, |ps| {
ps.flags.plot_viewport_borders = true;
ps.flags.update_paper = true;
ps.flags.zoom_to_paper_on_update = true;
ps.flags.unknown_bits = 0x8000;
ps.paper_image_origin_x = 3.5;
});
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert!(ps.flags.plot_viewport_borders);
assert!(ps.flags.update_paper && ps.flags.zoom_to_paper_on_update);
assert_eq!(ps.flags.unknown_bits, 0x8000);
assert_eq!(ps.paper_image_origin_x, 3.5);
}
/// Open the metric page-setup fixture written by the commercial
/// application (see `tests/fixtures/plot/README.md`).
#[cfg(not(target_arch = "wasm32"))]
fn app_with_fixture() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/plot/page-setups-metric.dxf"
);
// json! escapes the path: a Windows CARGO_MANIFEST_DIR carries
// backslashes, and a raw format! would put invalid \X escapes in the
// JSON string.
let reply = app.automation_op(
&serde_json::json!({"op": "open", "path": path}).to_string(),
);
assert_eq!(reply["ok"], true, "{reply}");
app
}
/// The page-setup fields the file format stores, minus handles.
fn stored_fields(ps: &codec::objects::PlotSettings) -> String {
format!(
"{} | {} | {:?} | {:.6} {:.6} | {:.6} {:.6} {:.6} {:.6} | {:.6} {:.6} | {} {} \
| {:.6} / {:.6} | {:.9} | {:?} | {:?} | {:?} {:?} {}",
ps.printer_name,
ps.paper_size,
ps.paper_units,
ps.paper_width,
ps.paper_height,
ps.margins.left,
ps.margins.bottom,
ps.margins.right,
ps.margins.top,
ps.origin_x,
ps.origin_y,
ps.rotation.to_code(),
ps.plot_type.to_code(),
ps.scale_numerator,
ps.scale_denominator,
ps.standard_scale_factor,
ps.scale_type,
ps.flags,
ps.shade_plot_mode,
ps.shade_plot_resolution,
ps.shade_plot_dpi
)
}
/// Every layout of the fixture survives "open the dialog, apply" without
/// a single stored field changing — the dialog reads the source
/// application's conventions and writes them back the same way.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn fixture_page_setups_round_trip_through_the_dialog_unchanged() {
use codec::objects::{PlotRotation, ScaledType};
let mut app = app_with_fixture();
let a3_medium = (297.0106506347656, 419.9889831542968);
let arch_d_medium = (914.4000244140626, 609.5999755859375);
let expectations = [
("A4 1-100", PlotRotation::None, (297.0, 210.0), true),
("A3 - plotter", PlotRotation::Degrees90, a3_medium, false),
("ARCH D - PLOTTER", PlotRotation::None, arch_d_medium, true),
];
for (layout, rotation, medium, pdf) in expectations {
let orientation = "Landscape";
let _ = app.update(Message::LayoutSwitch(layout.into()));
let i = app.active_tab;
let before = app.tabs[i].scene.plot_settings_for(layout).unwrap();
assert_eq!(before.rotation, rotation, "{layout}");
assert_eq!((before.paper_width, before.paper_height), medium, "{layout}");
assert_eq!(before.scale_type, ScaledType::OneToOne, "{layout}");
let _ = app.on_plot_dialog_open();
let d = &app.plot_dialog;
assert_eq!(d.orientation, orientation, "{layout}");
assert!(!d.upside_down);
assert_eq!(d.area, "Layout");
assert_eq!(d.scale, "1:1", "{layout}: a 1:1 plot in either paper unit");
assert_eq!(d.to_file, pdf, "{layout}");
assert_eq!(super::plot_dialog_sheet_mm(d).0 > super::plot_dialog_sheet_mm(d).1, true);
assert!(d.lineweights && d.apply_plot_styles && d.paperspace_last, "{layout}");
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let after = app.tabs[i].scene.plot_settings_for(layout).unwrap();
assert_eq!(stored_fields(&after), stored_fields(&before), "{layout}");
}
// The system printer's sheet keeps its driver's margins and lands on
// the layout the way the source application places it: the medium's
// top margin on the left, its left margin at the bottom.
let _ = app.update(Message::LayoutSwitch("A3 - plotter".into()));
let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
assert!((x0 + 20.02365112304687).abs() < 1e-9 && (y0 + 4.995333194732666).abs() < 1e-9);
assert!((x1 - 399.96533203125).abs() < 1e-6 && (y1 - 292.015317440033).abs() < 1e-6);
}
/// Picking a new sheet on the fixture's PDF layout writes our canonical
/// portrait medium with the orientation in the rotation and the PDF
/// driver's margins for that medium.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn fixture_pdf_layout_takes_a_new_sheet_in_canonical_form() {
use codec::objects::{PaperMargin, PlotRotation};
let mut app = app_with_fixture();
let _ = app.update(Message::LayoutSwitch("A4 1-100".into()));
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[app.active_tab].scene.plot_settings_for("A4 1-100").unwrap();
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
assert_eq!(ps.paper_size, "ISO_A3_(297.00_x_420.00_MM)");
assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0));
assert_eq!(ps.rotation, PlotRotation::Degrees90);
assert_eq!(ps.margins, PaperMargin::new(5.0, 17.0, 6.0, 18.0));
// The sheet sits at minus its displayed margins: top on the left,
// left at the bottom.
let ((x0, y0), _) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
assert!((x0 + 18.0).abs() < 1e-9 && (y0 + 5.0).abs() < 1e-9, "{x0} {y0}");
}
#[test]
fn hide_paperspace_objects_is_its_own_flag() {
use crate::ui::window::plot::PlotFlag;
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
assert!(!app.plot_dialog.hide_paperspace);
// Shade plot "Hidden Line" is a shade mode, not this flag.
let _ = app.on_plot_dlg(PlotDlgMsg::Shade("Hidden Line".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
assert!(!layout_settings(&app).flags.plot_hidden);
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::HidePaperspace));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
assert!(layout_settings(&app).flags.plot_hidden);
let _ = app.on_plot_dialog_open();
assert!(app.plot_dialog.hide_paperspace);
}
#[test]
fn switching_the_page_setup_unit_respells_offsets_and_keeps_the_output() {
use codec::objects::{PlotPaperUnits, ScaledType};
use crate::ui::window::plot::PlotFlag;
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let sheet_before = app.tabs[app.active_tab].scene.paper_limits().unwrap();
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::FitToPaper));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::Center));
let _ = app.on_plot_dlg(PlotDlgMsg::OffsetX("25.4".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into()));
let d = &app.plot_dialog;
assert_eq!(d.offset_x, "1", "25.4 mm is 1 inch");
assert_eq!((d.custom_scale_paper.as_str(), d.custom_scale_drawing.as_str()), ("1", "2540"));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.paper_units, PlotPaperUnits::Inches);
assert!((ps.origin_x - 25.4).abs() < 1e-9, "the file keeps millimetres");
assert_eq!(ps.scale_type, ScaledType::OneToHundred);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0));
// Back to millimetres: the same physical setup, spelled in mm.
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Millimeters".into()));
assert_eq!(app.plot_dialog.offset_x, "25.4");
assert_eq!(app.plot_dialog.custom_scale_drawing, "100");
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
// An inch page setup over the millimetre paper space leaves the
// sheet where it was.
let ps = layout_settings(&app);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4));
let sheet_after = app.tabs[app.active_tab].scene.paper_limits().unwrap();
let size = |((x0, y0), (x1, y1)): ((f64, f64), (f64, f64))| (x1 - x0, y1 - y0);
let (before, after) = (size(sheet_before), size(sheet_after));
assert!((before.0 - after.0).abs() < 1e-9 && (before.1 - after.1).abs() < 1e-9);
}
#[test]
fn custom_scale_fields_follow_and_drive_the_picker() {
use codec::objects::ScaledType;
use crate::ui::window::plot::PlotFlag;
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::FitToPaper));
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
let d = &app.plot_dialog;
assert_eq!((d.custom_scale_paper.as_str(), d.custom_scale_drawing.as_str()), ("1", "100"));
// A typed ratio the list knows is picked under its name…
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScalePaper("2".into()));
assert_eq!(app.plot_dialog.scale, "1:50");
// …one it does not know becomes a ratio entry of its own.
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("6".into()));
assert_eq!(app.plot_dialog.scale, "1:3");
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = layout_settings(&app);
assert_eq!(ps.scale_type, ScaledType::CustomScale);
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0));
// Half-typed or nonsense values leave the scale alone.
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("".into()));
assert_eq!(app.plot_dialog.scale, "1:3");
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("-4".into()));
assert_eq!(app.plot_dialog.scale, "1:3");
}
#[test]
fn the_plot_origin_counts_from_the_printable_corner_unless_plotoffset_says_edge() {
use crate::ui::window::plot::PlotFlag;
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::Center));
let _ = app.on_plot_dlg(PlotDlgMsg::OffsetX("10".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::OffsetY("20".into()));
// The PDF driver's margins on the drawing's landscape A4 medium:
// 5 left, 17 bottom.
assert_eq!(app.dialog_plot_origin_mm(), (15.0, 37.0));
let _ = app.run_command_line("PLOTOFFSET 1");
assert_eq!(app.dialog_plot_origin_mm(), (10.0, 20.0));
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into()));
let (x, y) = app.dialog_plot_origin_mm();
assert!((x - 10.0).abs() < 1e-4, "unit change keeps the distance");
assert!((y - 20.0).abs() < 1e-4);
}
#[test]
fn sending_a_plot_saves_the_dialog_into_the_layout_when_asked() {
use crate::ui::window::plot::PlotFlag;
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
assert!(app.plot_dialog.save_to_layout, "on by default, as expected of a page setup");
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
// A preview never writes the layout.
let _ = app.on_plot_dlg(PlotDlgMsg::Preview);
assert_eq!(layout_paper(&app).0, "A4");
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Commit);
assert_eq!(layout_paper(&app).0, "ISO_A3_(297.00_x_420.00_MM)");
// Switched off, a plot leaves the layout alone.
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::SaveToLayout));
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A2_(420.00_x_594.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::Commit);
assert_eq!(layout_paper(&app).0, "ISO_A3_(297.00_x_420.00_MM)");
}
#[test]
fn paperupdate_decides_what_happens_to_an_unsupported_sheet() {
use crate::io::paper_catalog::{resolve, Margins};
use crate::io::plot_device::{PrinterCapabilities, PrinterMedia};
let a3 = resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap();
let media = PrinterMedia { paper: a3.clone(), margins: Margins::uniform(3.0), borderless: false };
let caps = std::sync::Arc::new(PrinterCapabilities { media: vec![media], default_paper: Some(a3) });
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
// PAPERUPDATE 0: the sheet is kept and the mismatch reported.
let media = |caps| PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps));
let _ = app.on_plot_dlg(media(caps.clone()));
assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)");
let last = app.command_line.history.last().map(|line| line.text.clone());
assert!(last.as_deref().unwrap_or("").contains("PAPERUPDATE = 0"), "{last:?}");
// PAPERUPDATE 1: the printer's default sheet takes over, in the
// dialog's orientation.
let _ = app.run_command_line("PAPERUPDATE 1");
let _ = app.on_plot_dlg(media(caps));
assert_eq!(app.plot_dialog.paper, "ISO_A3_(297.00_x_420.00_MM)");
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0));
}
#[test]
fn scale_names_parse_to_their_ratios() {
use crate::scene::Scene;
assert_eq!(Scene::parse_scale_name_ratio("1:250"), Some((1.0, 250.0)));
assert_eq!(Scene::parse_scale_name_ratio("2:1"), Some((2.0, 1.0)));
assert_eq!(Scene::parse_scale_name_ratio("1/8\" = 1'-0\""), Some((0.125, 12.0)));
assert_eq!(Scene::parse_scale_name_ratio("3/32\" = 1'-0\""), Some((0.09375, 12.0)));
assert_eq!(Scene::parse_scale_name_ratio("1'-0\" = 1'-0\""), Some((12.0, 12.0)));
assert_eq!(Scene::parse_scale_name_ratio("6\" = 1'-0\""), Some((6.0, 12.0)));
assert_eq!(Scene::parse_scale_name_ratio("Fit"), None);
assert_eq!(Scene::parse_scale_name_ratio("1:0"), None);
assert_eq!(super::ratio_scale_name(0.004), "1:250");
assert_eq!(super::ratio_scale_name(25.4), "25.4:1");
assert_eq!(super::ratio_scale_name(1.0 / 3.0), "1:3");
}
#[test]
fn dialog_shows_the_catalogue_sheet_for_a_driver_name() {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)");
assert_eq!(app.plot_dialog.orientation, "Landscape");
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (297.0, 210.0));
}
#[test]
fn applying_an_unchanged_sheet_keeps_the_drawing_media_name() {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let (name, w, h) = layout_paper(&app);
assert_eq!(name, "A4", "the driver's own spelling must survive the dialog");
// …and so must the driver's landscape medium: unrotated, as stored.
assert_eq!((w, h), (297.0, 210.0));
assert_eq!(layout_rotation(&app), codec::objects::PlotRotation::None);
}
#[test]
fn picking_another_sheet_writes_the_canonical_name() {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let (name, w, h) = layout_paper(&app);
assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)");
assert_eq!((w, h), (297.0, 420.0));
assert_eq!(layout_rotation(&app), codec::objects::PlotRotation::Degrees90);
// The next dialog remembers the sheet the user chose.
assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)");
}
#[test]
fn pdf_output_is_stored_as_the_source_app_pdf_driver() {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
// A changed device takes the PDF driver's printable area for the sheet.
assert_eq!(ps.margins, codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0));
}
#[test]
fn legacy_pdf_label_is_upgraded_on_save() {
let mut app = app_with_printer_named_sheet();
let i = app.active_tab;
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
ps.printer_name = "Save to PDF file…".into();
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
let _ = app.on_plot_dialog_open();
assert!(app.plot_dialog.to_file, "the legacy label still means PDF output");
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
}
#[test]
fn a_plotter_configuration_from_source_app_is_preserved_with_its_margins() {
let mut app = app_with_printer_named_sheet();
let i = app.active_tab;
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
ps.printer_name = "DWF6 ePlot.pc3".into();
ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
ps.margins = codec::objects::PaperMargin::new(5.793749, 17.793753, 5.793744, 17.793747);
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
let _ = app.on_plot_dialog_open();
assert!(app.plot_dialog.to_file, "an unknown plotter plots to PDF here");
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let after = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(after.printer_name, "DWF6 ePlot.pc3");
assert_eq!(after.margins, ps.margins, "the source application's driver margins survive an unchanged setup");
}
#[test]
fn changing_the_sheet_updates_the_printable_area() {
let mut app = app_with_printer_named_sheet();
let i = app.active_tab;
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
ps.printer_name = "DWG To PDF.pc3".into();
ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
ps.margins = codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0);
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_full_bleed_A4_(210.00_x_297.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.paper_size, "ISO_full_bleed_A4_(210.00_x_297.00_MM)");
assert_eq!(ps.margins, codec::objects::PaperMargin::new(0.0, 1.0, 0.0, 1.0));
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.margins, codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0));
assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0));
}
#[test]
fn a_selected_printer_is_stored_by_name() {
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
assert!(!app.plot_dialog.to_file);
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.printer_name, "OCS Test Printer");
// Reopening resolves the stored name back to the same printer.
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.printer.as_deref(), Some("OCS Test Printer"));
// A media answer for a printer that is no longer selected is ignored.
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
let caps = std::sync::Arc::new(crate::io::plot_device::PrinterCapabilities::default());
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps)));
assert!(app.plot_dialog.printer_media.is_none());
}
#[test]
fn a_custom_sheet_is_added_selected_persisted_and_written_with_its_margins() {
use crate::ui::window::plot::{CustomPaperMsg as C, MarginSide};
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
let custom = |m: C| PlotDlgMsg::CustomPaper(m);
let _ = app.on_plot_dlg(custom(C::Open));
// Seeded from the selected ISO A4 on the PDF driver, nameless.
let draft = app.plot_dialog.custom_editor.clone().expect("editor open");
assert_eq!((draft.width.as_str(), draft.height.as_str()), ("210", "297"));
assert_eq!(draft.margins, ["5", "17", "6", "18"]);
assert!(draft.name.is_empty());
let _ = app.on_plot_dlg(custom(C::Name("Roll 24".into())));
let _ = app.on_plot_dlg(custom(C::Width("1500".into())));
let _ = app.on_plot_dlg(custom(C::Height("609.6".into())));
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "3".into())));
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Top, "4".into())));
let _ = app.on_plot_dlg(custom(C::Add));
assert!(app.plot_dialog.custom_editor.is_none(), "Add closes the editor");
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6));
assert_eq!(app.current_config().plot.custom_papers.len(), 1, "persisted in the plot config");
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)");
assert_eq!((ps.paper_width, ps.paper_height), (609.6, 1500.0));
assert_eq!(ps.rotation, codec::objects::PlotRotation::Degrees90);
assert_eq!(ps.margins, codec::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0));
// Re-adding the same size replaces the definition instead of duplicating it.
let _ = app.on_plot_dlg(custom(C::Open));
assert_eq!(app.plot_dialog.custom_editor.as_ref().map(|d| d.name.as_str()), Some("Roll 24"));
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "9".into())));
let _ = app.on_plot_dlg(custom(C::Add));
assert_eq!(app.plot_dialog.custom_papers.len(), 1);
assert_eq!(app.plot_dialog.custom_papers[0].margins.left, 9.0);
// Remove drops the definition; the sheet itself stays selectable.
let _ = app.on_plot_dlg(custom(C::Remove));
assert!(app.plot_dialog.custom_papers.is_empty());
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
}
#[test]
fn an_invalid_custom_sheet_is_refused_with_a_reason() {
use crate::ui::window::plot::{CustomPaperError, CustomPaperMsg as C};
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Open));
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("-5".into())));
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Add));
let draft = app.plot_dialog.custom_editor.as_ref().expect("editor stays open");
assert_eq!(draft.error, Some(CustomPaperError::Size));
assert!(app.plot_dialog.custom_papers.is_empty());
// Typing again clears the message.
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("500".into())));
assert!(app.plot_dialog.custom_editor.as_ref().unwrap().error.is_none());
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Cancel));
assert!(app.plot_dialog.custom_editor.is_none());
}
// The driver-options editor is the CUPS flow; on Windows
// PrinterProperties routes to the system preferences dialog instead, so
// the editor under test never opens there.
#[cfg(not(target_os = "windows"))]
#[test]
fn driver_options_are_edited_remembered_and_sent_with_the_job() {
use crate::io::print_to_printer::parse_lpoptions;
const LPOPTIONS: &str = "MediaType/Media Type: *Stationery Photographic\n\
ColorModel/Print Color Mode: *RGB Gray\n\
cupsPrintQuality/Print Quality: *Normal High\n";
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
let draft = app.plot_dialog.printer_editor.as_ref().expect("editor opens");
assert_eq!(draft.printer, "OCS Test Printer");
assert!(draft.options.is_none(), "options load in the background");
// An answer for another printer is ignored; the right one fills the editor.
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"Other".into(),
Ok(parse_lpoptions(LPOPTIONS)),
));
assert!(app.plot_dialog.printer_editor.as_ref().unwrap().options.is_none());
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"OCS Test Printer".into(),
Ok(parse_lpoptions(LPOPTIONS)),
));
let draft = app.plot_dialog.printer_editor.as_ref().unwrap();
assert_eq!(draft.options.as_ref().map(|o| o.len()), Some(3));
assert_eq!(draft.choices["ColorModel"], "RGB");
assert!(draft.overrides().is_empty(), "defaults are not overrides");
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("ColorModel".into(), "Gray".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("cupsPrintQuality".into(), "High".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
assert!(app.plot_dialog.printer_editor.is_none(), "Apply closes the editor");
let remembered = &app.plot_dialog.driver_options["OCS Test Printer"];
assert_eq!(remembered.len(), 2);
assert_eq!(remembered["ColorModel"], "Gray");
// The job carries exactly those overrides…
let opts = app.plot_print_options(&app.plot_dialog);
assert_eq!(
opts.driver_options,
vec![("ColorModel".to_string(), "Gray".to_string()), ("cupsPrintQuality".to_string(), "High".to_string())]
);
// …they survive in the config, and another printer gets none of them.
assert_eq!(app.current_config().plot.driver_options.len(), 1);
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("Another".into()));
assert!(app.plot_print_options(&app.plot_dialog).driver_options.is_empty());
// Reopening the editor starts from the remembered choices; Reset then
// Apply forgets them.
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"OCS Test Printer".into(),
Ok(parse_lpoptions(LPOPTIONS)),
));
assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().choices["ColorModel"], "Gray");
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsReset);
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
assert!(app.plot_dialog.driver_options.get("OCS Test Printer").is_none());
// A failed listing is reported inside the editor, not lost.
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"OCS Test Printer".into(),
Err("no cups".into()),
));
assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().error.as_deref(), Some("no cups"));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsCancel);
assert!(app.plot_dialog.printer_editor.is_none());
}
#[test]
fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() {
let mut app = app_with_printer_named_sheet();
let i = app.active_tab;
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
ps.paper_size = "Roll_24_(609.60_x_1500.00_MM)".into();
ps.paper_width = 1500.0;
ps.paper_height = 609.6;
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let (name, w, h) = layout_paper(&app);
assert_eq!(name, "Roll_24_(609.60_x_1500.00_MM)");
// The drawing's own landscape medium stays as it was stored.
assert_eq!((w, h), (1500.0, 609.6));
assert_eq!(layout_rotation(&app), codec::objects::PlotRotation::None);
}
}
#[cfg(test)]
mod plot_device_persistence_tests {
use super::*;
/// Reopening the Plot dialog must keep the user's printer. Layouts in
/// real drawings carry plot settings with no device, and the auto-applied
/// page setup used to reset the choice to the system default on every
/// open.
#[test]
fn reopening_the_plot_dialog_keeps_the_chosen_printer() {
let mut app = OpenCADStudio::new_for_test();
// A real drawing's layout carries plot settings with no device; the
// auto-applied setup used to reset the choice to the default.
let mut ps = codec::objects::PlotSettings::default();
ps.printer_name = String::new();
let layout = app.tabs[app.active_tab].scene.current_layout.clone();
assert!(
app.tabs[app.active_tab]
.scene
.set_layout_plot_settings(&layout, &ps),
"the test layout must accept plot settings"
);
app.plot_dialog.printer = Some("Brother DCP-L2520D series".into());
app.plot_dialog.to_file = false;
let _ = app.on_plot_dialog_open();
assert_eq!(
app.plot_dialog.printer.as_deref(),
Some("Brother DCP-L2520D series"),
"the chosen printer must survive the auto-applied page setup"
);
assert!(!app.plot_dialog.to_file);
}
/// A layout whose plot settings name an explicit device wins over the
/// saved preference: selecting its setup is an explicit device choice.
#[test]
fn layout_device_overrides_the_saved_preference() {
let mut app = OpenCADStudio::new_for_test();
app.plot_dialog.printer = Some("Brother DCP-L2520D series".into());
let mut ps = codec::objects::PlotSettings::default();
ps.printer_name = "Godex G500".into();
app.load_plotsettings_into_dialog(&ps);
assert_eq!(app.plot_dialog.printer.as_deref(), Some("Godex G500"));
assert!(!app.plot_dialog.to_file);
}
/// A persisted plot window (saved with the dialog settings) seeds the
/// runtime window when the dialog reopens, so `area == "Window"` keeps
/// plotting the same rectangle instead of reporting an empty area.
#[test]
fn persisted_window_seeds_the_dialog_on_reopen() {
let mut app = OpenCADStudio::new_for_test();
assert!(app.plot_window.is_none());
app.plot_dialog.area = "Window".into();
app.plot_dialog.window = Some((-10.0, -5.0, 30.0, 15.0));
let _ = app.on_plot_dialog_open();
assert_eq!(app.plot_window, Some((-10.0, -5.0, 30.0, 15.0)));
}
/// A window freshly loaded from a layout's plot settings is mirrored into
/// the dialog so it persists with the next config save.
#[test]
fn layout_window_is_mirrored_into_the_dialog() {
let mut app = OpenCADStudio::new_for_test();
let mut ps = codec::objects::PlotSettings::default();
ps.plot_type = codec::objects::PlotType::Window;
ps.set_plot_window(1.0, 2.0, 3.0, 4.0);
app.load_plotsettings_into_dialog(&ps);
assert_eq!(app.plot_window, Some((1.0, 2.0, 3.0, 4.0)));
assert_eq!(app.plot_dialog.window, Some((1.0, 2.0, 3.0, 4.0)));
}
/// A layout pointing at a PDF device switches the dialog to file output.
#[test]
fn pdf_device_switches_to_file_output() {
let mut app = OpenCADStudio::new_for_test();
app.plot_dialog.printer = Some("Brother DCP-L2520D series".into());
let mut ps = codec::objects::PlotSettings::default();
ps.printer_name = "Microsoft Print to PDF".into();
app.load_plotsettings_into_dialog(&ps);
assert!(app.plot_dialog.to_file);
assert!(app.plot_dialog.printer.is_none());
}
}
#[cfg(test)]
mod background_task_tests {
use super::background_task;
use crate::app::{Message, OpenCADStudio};
use iced::futures::StreamExt;
use iced::Task;
/// Pull the single message a finished background task produces, the same
/// way the headless automation driver consumes task streams.
fn next_message(task: Task<Message>) -> Message {
let mut stream =
iced_runtime::task::into_stream(task).expect("background_task yields a stream");
match iced::futures::executor::block_on(stream.next()) {
Some(iced_runtime::Action::Output(message)) => message,
other => panic!("expected task output, got {other:?}"),
}
}
#[test]
fn worker_panic_becomes_a_failure_message() {
let task =
background_task("STL export", || panic!("worker exploded"), |()| Message::Noop);
match next_message(task) {
Message::BackgroundTaskFailed { context, detail } => {
assert_eq!(context, "STL export");
assert!(
detail.contains("worker exploded"),
"detail should carry the panic message, got: {detail}"
);
}
other => panic!("expected BackgroundTaskFailed, got {other:?}"),
}
}
#[test]
fn worker_panic_is_reported_on_the_command_line() {
let mut app = OpenCADStudio::new_for_test();
let task =
background_task("STL export", || panic!("worker exploded"), |()| Message::Noop);
app.drive_headless_task(task)
.expect("failed worker task drives headlessly");
let error = app
.command_line
.last_error
.as_deref()
.expect("worker panic reported as an error");
assert!(error.contains("STL export"), "error = {error}");
assert!(error.contains("worker exploded"), "error = {error}");
}
}