Files
OneCAD-Draft/src/app/update/file.rs
T

7391 lines
317 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(in crate::app) 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.
///
/// Direct entities are resolved here from their authored color/layer. Expanded
/// block wires are different: `BlockCache` has already resolved their effective
/// ACI through ByBlock and layer-0/ByLayer inheritance. Their wire name points
/// at the host INSERT rather than the child entity that produced the geometry,
/// so resolving that INSERT again would destroy the child's effective ACI.
///
/// In particular, a true-color INSERT on an indexed-color layer would overwrite
/// an inherited child ACI with 0 and bypass the CTB entirely.
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;
};
// Expanded block geometry already carries the effective ACI calculated by
// BlockCache. `wire.name` identifies the host INSERT for picking, not the
// block child whose color inheritance produced `wire.aci`.
if matches!(entity, codec::EntityType::Insert(_)) {
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,
};
}
#[cfg(test)]
mod plot_owner_aci_tests {
use super::plot_owner_aci;
#[test]
fn expanded_block_wire_keeps_its_resolved_effective_aci() {
let mut scene = crate::scene::Scene::new();
// Real-world regression case:
// the INSERT itself has explicit true-color white, while its block
// children are layer 0 + ByLayer. BlockCache resolves those children
// through the INSERT's layer and stores that effective ACI on the wire.
let mut insert = codec::entities::Insert::new(
"*U264",
codec::types::Vector3::ZERO,
);
insert.common.color = codec::types::Color::Rgb {
r: 255,
g: 255,
b: 255,
};
let insert_handle = scene
.document
.add_entity(codec::EntityType::Insert(insert))
.expect("insert must be added");
let mut wire = crate::scene::WireModel::default();
wire.name = insert_handle.value().to_string();
// Pretend BlockCache resolved the layer-0 child to indexed color 5.
// The plot preparation step must preserve it instead of replacing it
// with ACI 0 from the host INSERT's true color.
wire.aci = 5;
plot_owner_aci(&scene, &mut wire);
assert_eq!(
wire.aci, 5,
"expanded block geometry must keep the effective ACI already resolved by BlockCache"
);
}
#[test]
fn direct_entity_still_refreshes_aci_from_its_owner() {
let mut scene = crate::scene::Scene::new();
let mut line = codec::entities::Line::from_points(
codec::types::Vector3::ZERO,
codec::types::Vector3::new(1.0, 0.0, 0.0),
);
line.common.color = codec::types::Color::Index(1);
let line_handle = scene
.document
.add_entity(codec::EntityType::Line(line))
.expect("line must be added");
let mut wire = crate::scene::WireModel::default();
wire.name = line_handle.value().to_string();
wire.aci = 5;
plot_owner_aci(&scene, &mut wire);
assert_eq!(
wire.aci, 1,
"ordinary entity plot wires must still resolve ACI from their owner"
);
}
}
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 an edge cache with them, for solids that
/// have no ACIS data before saving. A solid that has ACIS data keeps its
/// own wireframe cache: display edges are not written into a solid OCS
/// did not create or edit (those get their wires when they are built).
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)> = scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Solid3D(solid) = entity else {
return None;
};
let h = solid.common.handle;
(!solid.acis_data.has_data()).then_some((h, solid.wires.is_empty()))
})
.collect();
for (h, needs_wires) in targets {
let body = scene.solid_models.get(&h);
let sat = body.and_then(crate::scene::convert::acis_export::solid_to_sat);
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,
dyn_mode: self.dyn_mode,
polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg,
zoom_wheel_reversed: self.zoom_wheel_reversed,
touchpad_pan: self.touchpad_pan,
ui_scale_percent: self.ui_scale_percent,
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(),
surface_associativity: crate::scene::model::sweep_model::surface_associativity(),
ncopy_bind: self.ncopy_bind,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
snap_marker_color: self.snap_marker_color,
command_text_color: self.command_text_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(),
qnew_template: self.qnew_template.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,
count_invalid_area: self.count_palette.invalid_choice,
sheet_set: self.sheet_set.settings,
}
}
/// Apply restored preferences to live state.
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
self.dyn_mode = s.dyn_mode;
self.set_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.touchpad_pan = s.touchpad_pan;
self.ui_scale_percent = crate::app::settings::clamp_ui_scale(s.ui_scale_percent);
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);
crate::scene::model::sweep_model::set_surface_associativity(s.surface_associativity);
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.snap_marker_color = s.snap_marker_color;
self.snap_marker_color_input = s
.snap_marker_color
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.command_text_color = s.command_text_color;
self.command_text_color_input = s
.command_text_color
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
crate::ui::command_line::set_text_color(s.command_text_color);
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.qnew_template = s.qnew_template.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.count_palette.invalid_choice = s.count_invalid_area.min(2);
self.sheet_set.settings = s.sheet_set;
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());
self.shortcut_bindings
.entry("SHIFT+TAB".to_string())
.or_insert_with(|| "DYNTABPREV".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(())
}
}
}
/// Drop Plot-dialog state that belongs to the previously active drawing.
///
/// Plot settings such as paper size and CTB are document/layout data.
/// A newly opened DWG may reuse the same tab index and layout name as the
/// startup drawing, so `(tab, layout)` alone is not enough to identify the
/// context. Keeping that cache makes the first Plot dialog show the startup
/// A4/no-CTB state; the second opening then appears correct only because the
/// first one refreshed the runtime state.
fn reset_plot_session_for_opened_document(&mut self) {
self.plot_prev = None;
self.plot_prev_context = None;
self.plot_reconcile_printer_media = None;
self.plot_setup_template = None;
self.plot_window = None;
}
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();
self.tabs[i].scene.bump_ucs_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);
// Copies made by earlier releases kept their source's dimension
// association; such a file is reported as damaged elsewhere.
crate::scene::dimension_assoc::drop_stray_dimension_reactors(
&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();
}
// The tab may have been reused for the newly opened file. Any Plot
// dialog cache belongs to the previous/startup drawing and must not
// win over this DWG's embedded layout settings on the first Ctrl+P.
self.reset_plot_session_for_opened_document();
// 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().view.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()
};
let sheet_set_task = self.sheet_set_after_open(i);
Task::batch([thumbs_task, pending_open_task, interaction_task, startup_script_task, sheet_set_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) {
// A save is a field evaluation event (FIELDEVAL bit 2).
self.tabs[i].scene.update_fields(2, None);
self.prepare_snapshot_save(i);
}
/// Bring the drawing state the file stores up to date, without the save
/// event an autosave does not raise.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn prepare_snapshot_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();
self.stamp_sheet_set_data(i);
}
#[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 if ext.eq_ignore_ascii_case("dwt") {
("Drawing Template (*.dwt)", "dwt")
} 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_snapshot_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));
}
#[cfg(not(target_arch = "wasm32"))]
for path in &self.sheet_set.locks {
crate::app::commands::sheet_set::release_lock(path);
}
}
/// 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();
let ps = self.plot_settings_from_dialog(self.dialog_base_settings());
if layout_name == "Model" {
// Model keeps its page setup too (#1620); it has no sheet limits.
self.tabs[i].scene.set_layout_plot_settings(&layout_name, &ps);
self.tabs[i].dirty = true;
return;
}
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;
// Refresh runtime-only data such as printers, available media,
// CTB names, scales and page-setup rows.
//
// `on_plot_dialog_open()` also seeds the editor from the active
// layout. That is correct for normal PLOT, but Print All Options is a
// shared batch override editor: its persisted user choices must win.
let task = self.on_plot_dialog_open();
// Restore only user-editable values. `copy_settings_from` deliberately
// leaves the freshly refreshed runtime lists intact.
//
// Do this even on the first Print All Options opening after restarting
// OCS. Previously this happened only when `print_all_settings_override`
// was already true, so the first opening always fell back to the
// active layout/default A4 and lost the persisted CTB selection.
self.plot_dialog.copy_settings_from(&previous);
// Preserve the parsed CTB when it matches the restored selection.
// If it is absent/stale, dialog_plot_style() can resolve it by name.
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
}
/// A plot gives its fields (PlotDate) the plot time, as the reference
/// stores it; the hosts are redrawn before the plot reads the scene.
pub(in crate::app) fn stamp_plot_fields(&mut self) {
let i = self.active_tab;
let hosts = crate::entities::field::stamp_plot_fields(&mut self.tabs[i].scene.document);
if !hosts.is_empty() {
self.tabs[i].scene.bump_text_hosts(&hosts);
self.tabs[i].dirty = true;
}
}
fn print_all_pages(&mut self) -> Result<Vec<crate::io::pdf_export::PdfPageInput>, String> {
self.stamp_plot_fields();
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 cur = self.tabs[self.active_tab].scene.current_layout.clone();
let same_context = self
.plot_prev_context
.as_ref()
.is_some_and(|(tab, layout)| *tab == self.active_tab && layout == &cur);
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.clone());
let layout_entry = format!("*{cur}*");
if same_context {
// #1654: reopening Plot for the same drawing/layout must keep the
// values the user just edited. The layout page setup is the initial
// seed, not something that should overwrite paper, scale, CTB,
// orientation, area and output choices on every Ctrl+P.
//
// `copy_settings_from` deliberately leaves refreshed runtime lists
// (printers, media, scales, page setups, etc.) untouched.
self.plot_dialog.copy_settings_from(&previous);
self.plot_dialog.selected_setup = previous.selected_setup.clone();
// A custom ratio such as 1:3 may not exist in the drawing's named
// scale list. select_page_setup() normally adds it while loading a
// PlotSettings, but same-context reopening intentionally skips that
// reload. Keep the restored custom scale available to the picker so
// the validation below does not collapse it to 1:1.
if !self.plot_dialog.fit_to_paper
&& !self
.plot_dialog
.scales
.iter()
.any(|(name, _)| name == &self.plot_dialog.scale)
{
let factor = plot_dialog_scale_factor(&self.plot_dialog);
if factor.is_finite() && factor > 0.0 {
let name = self.plot_dialog.scale.clone();
self.plot_dialog.scales.push((name, factor));
self.plot_dialog.scales.sort_by(|a, b| {
a.1.partial_cmp(&b.1)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
}
} else {
// First open for this tab/layout: seed the editor from its actual
// page setup. An explicit setup selection later still uses
// `select_page_setup` and therefore intentionally replaces fields.
//
// Keep the chosen output device when a drawing page setup does not
// name one 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;
}
}
self.plot_prev_context = Some((self.active_tab, cur.clone()));
// 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);
// Loading printer capabilities is informational when Plot merely
// opens. It must not replace the sheet stored in the drawing.
self.request_printer_media(false)
}
/// 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, reconcile: bool) -> Task<Message> {
let Some(printer) = (!self.plot_dialog.to_file)
.then(|| self.plot_dialog.printer.clone())
.flatten()
else {
self.plot_dialog.printer_media = None;
self.plot_reconcile_printer_media = None;
return Task::none();
};
// PAPERUPDATE is relevant after the user explicitly changes the
// device/page setup. Merely reopening Plot must leave the drawing's
// stored paper untouched.
self.plot_reconcile_printer_media =
reconcile.then(|| printer.clone());
if let Some(caps) =
crate::io::plot_device::cached_printer_capabilities(&printer)
{
self.plot_dialog.printer_media = Some(caps);
if reconcile {
self.reconcile_sheet_with_printer(&printer);
}
return Task::none();
}
self.plot_dialog.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 => {
// Keep the user's last Plot choices available for the next
// invocation even when the dialog was simply closed.
self.save_config();
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();
// This is an explicit device change, so PAPERUPDATE may
// reconcile an unsupported sheet when its capabilities arrive.
self.request_printer_media(true)
}
M::PrinterMedia(printer, caps) => {
let still_current =
!self.plot_dialog.to_file
&& self.plot_dialog.printer.as_deref()
== Some(printer.as_str());
if still_current {
self.plot_dialog.printer_media = caps;
let reconcile = self
.plot_reconcile_printer_media
.as_deref()
.is_some_and(|pending| {
pending.eq_ignore_ascii_case(&printer)
});
if reconcile {
// Keep the explicit-device context alive while this
// printer remains selected. Capability updates for the
// same user-selected device must continue to obey
// PAPERUPDATE.
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) => {
// Print All Options edits the page setups represented by the
// layout rows. With "Save changes to layout" enabled, preserve
// the setup we are leaving before another one is loaded.
if self.print_all_options
&& self.plot_dialog.save_to_layout
&& self.plot_dialog.selected_setup != name
{
let previous = self.plot_dialog.selected_setup.clone();
if is_layout_entry(&previous) {
let layout = layout_entry_name(&previous).to_string();
self.apply_dialog_to_named_layout(&layout);
} else if previous
== crate::ui::window::plot::SETUP_PREV
{
// `<previous>` is a transient snapshot, not an actual
// page setup. `plot_prev_context` records which layout
// that snapshot came from.
//
// If the user returns to that same layout, commit the
// restored snapshot to it before reloading the row.
if let Some((tab, layout)) =
self.plot_prev_context.clone()
{
let target = format!("*{layout}*");
if tab == self.active_tab && name == target {
self.apply_dialog_to_named_layout(&layout);
}
}
}
}
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(true)
}
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();
}
/// Persist the current Plot editor into a specific layout.
///
/// This is needed by Print All Options because selecting `*Layout1*`,
/// `*Layout2*`, etc. does not switch `scene.current_layout`.
fn apply_dialog_to_named_layout(&mut self, layout_name: &str) {
use codec::objects::{PlotPaperUnits, PlotSettings};
let i = self.active_tab;
let base = self.tabs[i]
.scene
.plot_settings_for(layout_name)
.unwrap_or_else(|| {
let mut settings = PlotSettings::new("");
settings.paper_units = PlotPaperUnits::Millimeters;
settings
});
let ps = self.plot_settings_from_dialog(base);
if self.tabs[i]
.scene
.set_layout_plot_settings(layout_name, &ps)
{
self.tabs[i].dirty = true;
if self.tabs[i].scene.current_layout == layout_name {
self.tabs[i].scene.bump_geometry_no_blocks();
}
}
}
/// 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 {
self.apply_dialog_to_layout();
} else {
self.sync_dialog_plot_runtime();
}
self.active_modal = None;
self.reset_modal_geometry();
if !preview {
self.stamp_plot_fields();
}
// 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;
}
// Fast path: the selected table is already active.
if let Some(table) = self
.active_plot_style
.as_ref()
.filter(|table| table.name.eq_ignore_ascii_case(&d.style_name))
{
return Some(table.clone());
}
// The dialog selection is persisted separately from the parsed CTB
// object. After reopening a drawing the name may be correct while the
// in-memory table is still the previous/default one. Plotting must not
// silently ignore the selected CTB in that state.
crate::io::plot_style::PlotStyleTable::load_named(&d.style_name).ok()
}
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(())?;
let center = crate::scene::named_view_center(view);
let (cx, cy) = (center.x, center.y);
self.area_plot_job((cx - half_w, cy - half_h, cx + half_w, cy + 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.
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().view.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);
}
/// Reopening Plot in the same tab/layout keeps the user's most recent
/// paper, scale and CTB choices instead of reloading the layout page setup
/// over them. Regression test for #1654.
#[test]
fn reopening_same_plot_context_keeps_recent_settings() {
let mut app = OpenCADStudio::new_for_test();
// First open seeds from the drawing/layout as usual.
let _ = app.on_plot_dialog_open();
// User edits the high-frequency Plot settings.
app.plot_dialog.paper = "ISO_A3_(297.00_x_420.00_MM)".into();
app.plot_dialog.paper_width_mm = 297.0;
app.plot_dialog.paper_height_mm = 420.0;
app.plot_dialog.scale = "1:100".into();
app.plot_dialog.fit_to_paper = false;
app.plot_dialog.style_name = "office.ctb".into();
app.plot_dialog.apply_plot_styles = true;
// Reopening Ctrl+P in the same layout must not reapply the layout's
// old page setup and erase those choices.
let _ = app.on_plot_dialog_open();
assert_eq!(
app.plot_dialog.paper,
"ISO_A3_(297.00_x_420.00_MM)"
);
assert_eq!(app.plot_dialog.scale, "1:100");
assert!(!app.plot_dialog.fit_to_paper);
assert_eq!(app.plot_dialog.style_name, "office.ctb");
assert!(app.plot_dialog.apply_plot_styles);
}
/// Opening a different drawing in a reused tab must invalidate the Plot
/// session cache. The first Plot dialog must read paper and CTB from the
/// newly opened document instead of showing the startup drawing's values.
#[test]
fn newly_opened_document_forces_first_plot_dialog_to_read_layout() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
app.tabs[i].scene.current_layout = "Layout1".into();
let mut ps = app.tabs[i]
.scene
.plot_settings_for("Layout1")
.expect("Layout1 must exist");
ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into();
ps.paper_width = 420.0;
ps.paper_height = 594.0;
ps.current_style_sheet =
crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
ps.flags.plot_plot_styles = true;
assert!(
app.tabs[i]
.scene
.set_layout_plot_settings("Layout1", &ps)
);
// Reproduce stale state inherited from the startup/previous drawing.
app.plot_dialog.paper =
"ISO_A4_(210.00_x_297.00_MM)".into();
app.plot_dialog.style_name.clear();
app.plot_dialog.apply_plot_styles = false;
app.plot_prev = Some(app.plot_dialog.clone());
app.plot_prev_context =
Some((i, "Layout1".into()));
// Installing another drawing must make that state unusable.
app.reset_plot_session_for_opened_document();
assert!(app.plot_prev.is_none());
assert!(app.plot_prev_context.is_none());
let _ = app.on_plot_dialog_open();
assert_eq!(
app.plot_dialog.paper,
"ISO_A2_(420.00_x_594.00_MM)",
"the first Plot opening must use the new drawing's paper"
);
assert!(
app.plot_dialog
.style_name
.eq_ignore_ascii_case(
crate::io::plot_style::MONOCHROME_PLOT_STYLE
),
"the first Plot opening must use the new drawing's CTB"
);
assert!(
app.plot_dialog.apply_plot_styles,
"the stored CTB must already be enabled on the first Plot opening"
);
}
/// Print All Options is a shared batch-settings editor. On the first
/// opening after restarting OCS it must keep the persisted user choices
/// instead of replacing them with the active layout's page setup.
#[test]
fn print_all_options_keeps_persisted_plot_preferences_on_first_open() {
let mut app = OpenCADStudio::new_for_test();
// Simulate preferences restored from the previous application session.
app.plot_dialog.paper =
"ISO_A2_(420.00_x_594.00_MM)".into();
app.plot_dialog.paper_width_mm = 420.0;
app.plot_dialog.paper_height_mm = 594.0;
app.plot_dialog.scale = "1:1000".into();
app.plot_dialog.style_name =
crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
app.plot_dialog.apply_plot_styles = true;
// Fresh process: no explicit Print All override exists yet.
app.print_all_settings_override = false;
let _ = app.on_print_all_options();
assert_eq!(
app.plot_dialog.paper,
"ISO_A2_(420.00_x_594.00_MM)",
"first Print All Options opening must keep the persisted paper"
);
assert_eq!(
app.plot_dialog.scale,
"1:1000",
"first Print All Options opening must keep the persisted scale"
);
assert!(
app.plot_dialog
.style_name
.eq_ignore_ascii_case(
crate::io::plot_style::MONOCHROME_PLOT_STYLE
),
"first Print All Options opening must keep the persisted CTB"
);
assert!(
app.plot_dialog.apply_plot_styles,
"the persisted CTB must remain enabled"
);
assert!(
app.print_all_options,
"the dialog must remain in Print All Options mode"
);
}
/// `<previous>` belongs to the layout that produced the snapshot.
/// Returning to that layout with "Save changes to layout" enabled must
/// preserve the restored paper/CTB instead of reloading the old embedded
/// page setup over it.
#[test]
fn previous_snapshot_is_saved_back_to_its_origin_layout() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
app.tabs[i].scene.current_layout = "Layout1".into();
// Layout1 still contains an old page setup.
let mut stored = app.tabs[i]
.scene
.plot_settings_for("Layout1")
.expect("Layout1 must exist");
stored.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
stored.paper_width = 210.0;
stored.paper_height = 297.0;
stored.current_style_sheet.clear();
stored.flags.plot_plot_styles = false;
assert!(
app.tabs[i]
.scene
.set_layout_plot_settings("Layout1", &stored)
);
// `<previous>` is the desired state that originally belonged to
// Layout1.
app.print_all_options = true;
app.plot_prev_context = Some((i, "Layout1".into()));
app.plot_dialog.selected_setup =
crate::ui::window::plot::SETUP_PREV.into();
app.plot_dialog.paper =
"ISO_A2_(420.00_x_594.00_MM)".into();
app.plot_dialog.paper_width_mm = 420.0;
app.plot_dialog.paper_height_mm = 594.0;
app.plot_dialog.style_name =
crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
app.plot_dialog.apply_plot_styles = true;
app.plot_dialog.save_to_layout = true;
let _ = app.on_plot_dlg(
crate::ui::window::plot::PlotDlgMsg::SelectSetup(
"*Layout1*".into(),
),
);
let saved = app.tabs[i]
.scene
.plot_settings_for("Layout1")
.expect("Layout1 must still exist");
assert_eq!(
saved.paper_size,
"ISO_A2_(420.00_x_594.00_MM)"
);
assert!(
saved.current_style_sheet.eq_ignore_ascii_case(
crate::io::plot_style::MONOCHROME_PLOT_STYLE
)
);
assert!(saved.flags.plot_plot_styles);
assert_eq!(
app.plot_dialog.paper,
"ISO_A2_(420.00_x_594.00_MM)"
);
assert!(
app.plot_dialog
.style_name
.eq_ignore_ascii_case(
crate::io::plot_style::MONOCHROME_PLOT_STYLE
)
);
}
/// A CTB selected in the dialog must be usable even when the parsed
/// active table was not restored with it. This is the state seen after
/// reopening a drawing: the dropdown can name the correct CTB while the
/// first preview otherwise plots using raw layer colours.
#[test]
fn selected_ctb_is_resolved_when_active_table_is_stale() {
let mut app = OpenCADStudio::new_for_test();
app.plot_dialog.style_name =
crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
app.plot_dialog.apply_plot_styles = true;
app.plot_dialog.style_missing = false;
// Reproduce a fresh-session mismatch: the visible selection exists,
// but no matching parsed table is active yet.
app.active_plot_style = None;
let table = app
.dialog_plot_style(&app.plot_dialog)
.expect("selected CTB must be resolved by name");
assert!(
table
.name
.eq_ignore_ascii_case(
crate::io::plot_style::MONOCHROME_PLOT_STYLE
),
"the table used for plotting must match the visible CTB selection"
);
}
/// Printer capability discovery on merely opening Plot must never mutate
/// the paper stored in the drawing. PAPERUPDATE is applied only after an
/// explicit device/page-setup choice.
#[test]
fn opening_plot_does_not_replace_sheet_when_printer_media_arrives() {
use crate::io::paper_catalog::{resolve, Margins};
use crate::io::plot_device::{
PrinterCapabilities,
PrinterMedia,
};
let a2 =
resolve("ISO_A2_(420.00_x_594.00_MM)").expect("A2");
let a4 =
resolve("ISO_A4_(210.00_x_297.00_MM)").expect("A4");
let caps = std::sync::Arc::new(PrinterCapabilities {
media: vec![PrinterMedia {
paper: a4.clone(),
margins: Margins::uniform(3.0),
borderless: false,
}],
default_paper: Some(a4),
});
let mut app = OpenCADStudio::new_for_test();
app.plot_dialog.to_file = false;
app.plot_dialog.printer =
Some("OCS Test Printer".into());
app.plot_dialog.paper = a2.canonical.to_string();
app.plot_dialog.paper_update = 1;
// No explicit printer change is pending: this models the asynchronous
// capability reply caused only by opening the Plot dialog.
app.plot_reconcile_printer_media = None;
let _ = app.on_plot_dlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(
"OCS Test Printer".into(),
Some(caps),
));
assert_eq!(
app.plot_dialog.paper,
a2.canonical,
"opening Plot must preserve the drawing's stored sheet"
);
}
/// 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}");
}
}