//! `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( context: impl Into, work: F, map: M, ) -> Task 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) -> String { if let Some(text) = payload.downcast_ref::<&'static str>() { (*text).to_string() } else if let Some(text) = payload.downcast_ref::() { 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 { 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 { 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"))] pub(in crate::app) 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 { 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, ) -> 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| { 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::>() }; 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::>(); 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). pub(in crate::app) 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(), ucs_ortho: crate::modules::view::view_cmd::ucs_ortho(), ucs_view: crate::modules::view::view_cmd::ucs_view(), 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 = 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(), color_books: self.color_book_paths.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); crate::modules::view::view_cmd::set_ucs_ortho(s.ucs_ortho); crate::modules::view::view_cmd::set_ucs_view(s.ucs_view); 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.color_book_paths = s.color_books.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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }, show_graphic_attributes: self.show_graphic_attributes, graphic_fills_on_current_layer: self.graphic_attributes.fill_on_current_layer, 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(); // A saved `System` scheme re-resolves at boot: the stored palette is // whatever the last resolution produced, so refresh it alongside the // live theme (COLORSCHEME SYSTEM documents this boot-time read). if self.ui_theme.name.eq_ignore_ascii_case("System") { self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced( crate::app::config::resolve_system_theme().seed(), ); } 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.show_graphic_attributes = cfg.show_graphic_attributes; self.graphic_attributes.fill_on_current_layer = cfg.graphic_fills_on_current_layer; if self.show_graphic_attributes { self.dock.dock_below_properties(crate::ui::dock::PanelId::GraphicAttributes); } self.ribbon .set_graphic_attributes(self.show_graphic_attributes); 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()); crate::app::shortcuts::sync_space_dispatch_flag(&self.shortcut_bindings); } /// 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 { // 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. // Web guard: a second open (Ctrl+O, the OPEN command, host // automation) while a drop/restore/picker open is still in flight // would overwrite `self.opening` — the in-flight open's // `WebFileOpened` then misses the id check and its bytes are lost, // and any pending restore prompt stalls for the session. Native // keeps the old behaviour: its `OpenPathPicked` round-trip is // guarded downstream. #[cfg(target_arch = "wasm32")] if self.opening.is_some() { self.command_line .push_info(crate::tf!("An open is already in progress.").as_ref()); return Task::none(); } #[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) -> Task { 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) { 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 { #[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> { 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 { 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, ) { 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, ) { 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 { // 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(); } // Web drawings live under bare file names: two tabs sharing a name // also share the single browser-stored copy, and the last save // silently replaces the other tab's. Warn at open time — the // desktop identifies drawings by full path and needs no such check. #[cfg(target_arch = "wasm32")] if self.tab_showing(&path).is_some() { self.command_line.push_warning( crate::tf!( "\"{}\" is already open in another tab. On the web both \ tabs share one stored copy — the last save overwrites \ the other.", name ) .as_ref(), ); } 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(), ); } // Web: NotFound xrefs are summarized in one warning after the // loop (see below) — the per-reference line carries a // filesystem path no browser can open, and a drawing with // several unresolved xrefs would repeat the same hint. #[cfg(not(target_arch = "wasm32"))] crate::io::xref::XrefStatus::NotFound => { self.command_line.push_error( crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(), ); } #[cfg(target_arch = "wasm32")] crate::io::xref::XrefStatus::NotFound => {} 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(), ); } } } // Web: one warning per open lists every unresolved xref and the way // to fix it — dropping the files on the page registers their bytes, // and reopening the drawing (or XRELOAD) then resolves them. #[cfg(target_arch = "wasm32")] self.warn_unresolved_xrefs(&caches.xrefs); #[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 }; // A Restore-all batch's LAST restore inserts its tab here — one // async round AFTER its settle message. The focus flag therefore // also fires here: the message-boundary hook alone could run // before this insertion and be overwritten by it. #[cfg(target_arch = "wasm32")] if self.web_session_focus_pending && self.web_autosave_restore_all_remaining == 0 { self.web_session_focus_pending = false; let _ = self.select_remembered_session_tab(); } // 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(); // The tab list just changed (and the active tab with it): remember // it for the next boot's restore flow (web session memory). #[cfg(target_arch = "wasm32")] self.persist_web_session(); 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 { 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(); // Native: extraction and the file write both run on a background // thread, unchanged. #[cfg(not(target_arch = "wasm32"))] { 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), ) } // Web: extraction is pure document manipulation (it reads the clone // above and builds a fresh document) — run it inline on the main // thread as the prepare step, together with the shared save // preparation, then hand the DWG/DXF writer pass to the serializer // worker the way the web Save flow does // (`serialize_prepared_document` falls back inline when the worker // cannot help). Completion (`WebWblockSerialized`) delivers the // browser download plus the same `WblockWriteFinished` command-line // bookkeeping. #[cfg(target_arch = "wasm32")] { let extracted = if block_name == "*" { crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) } else { crate::modules::insert::wblock::extract_block_to_doc(&document, &block_name) } .map_err(|e| e.to_string()); let document = match extracted { Ok(document) => document, // Same reporting the background path used for a failed // extraction — straight through the existing message. Err(error) => { return Task::done(Message::WblockWriteFinished( block_name, path, Err(error), )) } }; let ext = path .extension() .map(|ext| ext.to_string_lossy().into_owned()) .unwrap_or_else(|| "dwg".to_string()); let filename = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "block.dwg".into()); let version = document.version; let prepared = crate::io::prepare_document_for_save(document, version); Task::perform( async move { crate::io::serialize_prepared_document(&prepared, &ext).await }, move |result| Message::WebWblockSerialized { block_name, path, filename, result, }, ) } } /// Web SAVEALL: every dirty tab that already has a name-only path is /// serialized and downloaded in one pass — the worker-routed counterpart /// of a single web Save. Each tab is prepared (state sync + clone + /// style/dimension bake) once up front on the main thread, then the /// DWG/DXF writer passes run serially on the serializer worker — the /// same single-future structure the autosave round uses. The browser may /// ask once for permission to download several files, which is the /// platform's price. Clean tabs are skipped (re-downloading identical /// bytes is noise); tabs with no path still need SAVEAS. Completion /// (`on_web_saveall_serialized`) fires the downloads and clears each /// tab's dirty flag — but only if the tab did not change while its /// writer ran. #[cfg(target_arch = "wasm32")] pub(in crate::app) fn saveall_web(&mut self) -> Task { let mut skipped = 0usize; let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new(); for t in 0..self.tabs.len() { if self.tabs[t].is_start { continue; } let Some(path) = self.tabs[t].current_path.clone() else { skipped += 1; continue; }; if !self.tabs[t].dirty { continue; } // The same preparation a single web Save makes on the tab. self.sync_view_state_for_save(t); sync_annotation_scale_header(&mut self.tabs[t].scene); self.stamp_header_sysvars(t); self.sync_solid_models_for_save(t); self.tabs[t].scene.sync_native_parametric_graph(); let ext = path .extension() .map(|ext| ext.to_string_lossy().into_owned()) .unwrap_or_else(|| "dwg".to_string()); let version = self.tabs[t].scene.document.version; let filename = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); // Race guard (the same snapshot a single web Save takes): the // markers are read after the state syncs, so completion only // clears the dirty flag if the tab did not change while the // writer ran off-thread. let tab = &self.tabs[t]; let entry = crate::app::WebSaveallEntry { tab_id: tab.id, path, filename, ext, version, geometry_epoch: tab.scene.geometry_epoch, edit_revision: tab.edit_revision, camera_generation: tab.scene.camera_generation, // Placeholder — the serialization future below overwrites // it before the completion message is built. result: Ok(Vec::new()), }; pending.push(( entry, crate::io::prepare_document_for_save(tab.scene.document_for_save(), version), )); } if pending.is_empty() { self.report_saveall_web(0, skipped); return Task::none(); } Task::perform( async move { let mut results = Vec::with_capacity(pending.len()); for (mut entry, prepared) in pending { entry.result = crate::io::serialize_prepared_document(&prepared, &entry.ext).await; results.push(entry); } results }, move |results| Message::WebSaveallSerialized { skipped, results }, ) } /// Web SAVEALL completion: the serializer worker (or inline fallback) /// produced each tab's bytes. The download is the only artifact of a /// tab's save — fire it even if the tab was closed while serialization /// was in flight. Bookkeeping matches the old synchronous pass (version /// stamped, recovery flag cleared, dirty cleared under the race guard, /// one error line per failed tab), then the shared summary line. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_saveall_serialized( &mut self, skipped: usize, results: Vec, ) -> Task { let mut saved = 0usize; for entry in results { match entry.result { Ok(bytes) => { crate::sys::download_bytes(&entry.filename, &bytes); saved += 1; let Some(i) = self.tabs.iter().position(|tab| tab.id == entry.tab_id) else { continue; }; self.tabs[i].scene.document.version = entry.version; self.tabs[i].recovery_save_as_required = false; let snapshot_is_current = self.tabs[i].scene.geometry_epoch == entry.geometry_epoch && self.tabs[i].edit_revision == entry.edit_revision && self.tabs[i].scene.camera_generation == entry.camera_generation; if snapshot_is_current { self.tabs[i].dirty = false; } } Err(e) => self.command_line.push_error( crate::tf!("SAVEALL: {} failed: {e}", entry.path.display()).as_ref(), ), } } self.report_saveall_web(saved, skipped); Task::none() } /// The SAVEALL summary line — shared by the nothing-queued early return /// and the worker completion. #[cfg(target_arch = "wasm32")] fn report_saveall_web(&mut self, saved: usize, skipped: usize) { self.command_line.push_output( crate::tf!( "SAVEALL: saved {saved} drawing(s){}.", if skipped > 0 { crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned() } else { String::new() } ) .as_ref(), ); } pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task { // 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 = 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 { let i = self.active_tab; // Export uses LOD 0 (full resolution); see StlExportPath above. let meshes: Vec = 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 { 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), ) } pub(in crate::app) 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, ) -> Result< ( Option, Option, ), 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 { 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 { 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, screenshot: Option, ) -> Task { self.thumbnail_capture_clean = false; let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { // The tab closed while its capture was queued: the save is over // (no serialization follows), so release the in-flight slot. crate::io::fs_access::end_web_save(tab_id); 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); } // The name the autosave snapshot for this tab is keyed by — capture it // before the save re-points `current_path` at the download name. let autosave_key = Self::web_autosave_key(&self.tabs[i]); // Race guard (the web counterpart of native's `snapshot_is_current`): // completion only clears the dirty flag if the tab did not change // while the writer ran off-thread. let geometry_epoch = self.tabs[i].scene.geometry_epoch; let edit_revision = self.tabs[i].edit_revision; let camera_generation = self.tabs[i].scene.camera_generation; // Prepare the document here (clone + style sync + dimension bake — the // app-side half of the pipeline), then hand the DWG/DXF writer pass to // the browser worker so the UI thread stays responsive on large // drawings. Completion (download + bookkeeping) continues in // `on_web_save_serialized`. let prepared = crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version); Task::perform( async move { crate::io::serialize_prepared_document(&prepared, &ext).await }, move |result| Message::WebSaveSerialized { tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, result, }, ) } /// Web SAVE completion: the serializer worker (or inline fallback) has the /// drawing bytes. When the tab still exists AND holds a File System /// Access handle picked for the format these bytes were serialized as, /// they go to it in place (createWritable needs no user gesture, so the /// write may happen long after the pick); completion continues in /// `on_web_save_written`. Anything else takes the legacy download — the /// only artifact of the save, launched even if the tab was closed while /// serialization was in flight. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_serialized( &mut self, tab_id: u64, filename: String, autosave_key: String, version: codec::DxfVersion, geometry_epoch: u64, edit_revision: u64, camera_generation: u64, result: Result, String>, ) -> Task { let bytes = match result { Ok(bytes) => bytes, Err(error) => { self.command_line .push_error(crate::tf!("Save failed: {error}").as_ref()); crate::io::fs_access::end_web_save(tab_id); return self.finish_web_save(false); } }; // In-place write eligibility, all three required: // * the tab still exists — a tab that closed while serialization ran // means the user declined this save (discard/close), and the // legacy contract for closed tabs is download-only; // * a handle is held AND it was picked for the format these bytes // were serialized for (the pending ext recorded at capture time): // a Save As whose re-pick failed falls back to the download, and // the stale handle from the previous format must not receive the // fallback bytes (a .dwg file must never silently hold DXF). let tab_exists = self.tabs.iter().position(|tab| tab.id == tab_id).is_some(); let target_ext = crate::io::fs_access::take_pending_save_ext(tab_id); let write_in_place = tab_exists && crate::io::fs_access::write_or_download( crate::io::fs_access::handle_ext(tab_id).as_deref(), target_ext.as_deref(), ); if !write_in_place { // Whatever handle is held is stale for these bytes (wrong // format, unverifiable format, or a tab that declined the // save): drop it so the next save re-picks instead of hitting // the same mismatch. No handle held: the drop is a no-op. crate::io::fs_access::drop_handle(tab_id); } else if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) { // The bookkeeping runs at write completion (WebSaveWritten) so a // failed write cannot clear the dirty flag; the bytes ride back // with the completion message so the OPFS recent store gets the // same copy the download path uses. let download_name = picked_name.clone(); return Task::perform( async move { let result = if let Err(error) = crate::io::fs_access::write_handle(tab_id, &bytes).await { // Keep the save alive: the drawing still lands // somewhere via the legacy download (which needs no // activation), and the error travels with the // completion message for the command-line note. crate::sys::download_bytes(&download_name, &bytes); Err(error) } else { Ok(()) }; (result, bytes) }, move |(result, bytes)| Message::WebSaveWritten { tab_id, filename: picked_name, autosave_key, version, geometry_epoch, edit_revision, camera_generation, result, bytes, }, ); } // The download is the only artifact of the save — fire it even if the // tab was closed while serialization was in flight. crate::sys::download_bytes(&filename, &bytes); self.finalize_web_save( tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, bytes, ) } /// Web SAVE in-place completion: the retained handle's write settled. /// Success keeps the handle for the next plain Save; failure drops it /// (the task already downloaded a copy — note that in the command line, /// and re-pick on the next save instead of failing the same way again). #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_written( &mut self, tab_id: u64, filename: String, autosave_key: String, version: codec::DxfVersion, geometry_epoch: u64, edit_revision: u64, camera_generation: u64, result: Result<(), String>, bytes: Vec, ) -> Task { if let Err(error) = result { crate::io::fs_access::drop_handle(tab_id); self.command_line.push_error( crate::tf!( "In-place save failed ({error}); a copy was downloaded instead" ) .as_ref(), ); } self.finalize_web_save( tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, bytes, ) } /// Shared completion half of the web save — download and in-place write /// alike: recent-copy store task, tab bookkeeping under the race guard /// (the dirty flag only clears when the persisted snapshot is still the /// tab's current state), autosave-key retirement and the "Saved" line. #[cfg(target_arch = "wasm32")] fn finalize_web_save( &mut self, tab_id: u64, filename: String, autosave_key: String, version: codec::DxfVersion, geometry_epoch: u64, edit_revision: u64, camera_generation: u64, bytes: Vec, ) -> Task { // The save settled (download or in-place write): release the // in-flight slot the on_save_file guard refuses against. crate::io::fs_access::end_web_save(tab_id); 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); let mut recent_task = Task::perform( async move { crate::io::web_recent::store(&path.to_string_lossy(), &bytes) .await .map(|_| path) }, Message::WebRecentStored, ); let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { return recent_task; }; self.tabs[i].current_path = Some( std::path::Path::new(&filename) .file_name() .map(|name| std::path::PathBuf::from(name.to_string_lossy().into_owned())) .unwrap_or_else(|| std::path::PathBuf::from(filename.clone())), ); self.tabs[i].scene.document.version = version; self.tabs[i].recovery_save_as_required = false; // A save may have renamed the tab (Save As): refresh the remembered // session list (web session memory). self.persist_web_session(); let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch && self.tabs[i].edit_revision == edit_revision && self.tabs[i].scene.camera_generation == camera_generation; if snapshot_is_current { self.tabs[i].dirty = false; } // A clean save supersedes any crash-recovery snapshot for this // drawing — drop the old key and the new one so stale copies don't // resurface in the next boot scan. But an autosave round may still be // writing the same key: its write would land AFTER the remove and // resurrect the snapshot (with a newer mtime, so the next scan would // offer it as recoverable). Defer the removes to the end of the round. let new_key = self.tabs[i] .current_path .as_ref() .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); if self.web_autosave_in_flight { self.web_autosave_clear_after.push(autosave_key); if !new_key.is_empty() { self.web_autosave_clear_after.push(new_key); } } else { recent_task = Task::batch([ recent_task, Task::perform( async move { let _ = crate::io::web_autosave::remove(&autosave_key).await; let _ = crate::io::web_autosave::remove(&new_key).await; }, |_| Message::Noop, ), ]); } self.command_line .push_output(crate::tf!("Saved: {filename}").as_ref()); Task::batch([recent_task, self.finish_web_save(true)]) } /// Tail of the web save flow: continue a pending close when the save /// succeeded, or re-open the unsaved-changes dialog when it failed. #[cfg(target_arch = "wasm32")] fn finish_web_save(&mut self, saved: bool) -> Task { if self.save_dialog_for_unsaved { if saved { // Only a Tab pending is consumed here; a Quit pending must // survive to continue the quit after this save. if let Some(crate::app::PendingClose::Tab(tab_id)) = self .pending_close .take_if(|pending| matches!(pending, crate::app::PendingClose::Tab(_))) { let continuation = self.update(Message::TabClose(tab_id)); let rest = self.continue_tab_close_queue(); return Task::batch([continuation, rest]); } } else if matches!(self.pending_close, Some(crate::app::PendingClose::Quit)) { // Mirror the native Quit continuation: offer to save the // next dirty tab, or finish the quit. if self.tabs.iter().any(|tab| tab.dirty) { return self.open_unsaved_dialog_window(); } return Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]); } else if self.pending_close.is_some() { return self.open_unsaved_dialog_window(); } } Task::none() } #[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 { 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| { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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")] { // The web counterpart of native's active_save_jobs guard: one // capture → serialize → write/download chain at a time. The // window is seconds, so refuse rather than queue — same // localized message the native flow shows. if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() { if self.tabs.iter().any(|tab| tab.id == busy_tab) { self.command_line .push_info(crate::t!("Save already running for this drawing.").as_ref()); return Task::none(); } // Stale marker (its tab closed before the save could // settle): clear it instead of blocking every later save. crate::io::fs_access::end_web_save(busy_tab); } 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 { self.active_tab = tab_idx; // Every caller of this entry (Save/QSAVE, the save-before-close // flow) is plain-save semantics: on the web, the tab's retained // FS Access handle may be written through directly. Save As opens // the dialog modal instead (open_save_dialog_window resets this). #[cfg(target_arch = "wasm32")] { self.save_plain_reuse_fs_handle = true; } 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 { 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 path — the destination is either a retained // File System Access handle (in-place write), a fresh // showSaveFilePicker pick, or the legacy 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; // File System Access routing, decided HERE: showSaveFilePicker // may only run while the page holds the Save click's transient // user activation, and the thumbnail capture + worker // serialization that follow can outlast that window. So the // handle is secured at this gesture moment (or reused from a // previous pick — retained handles need no activation to write) // and only the write happens later. let route = crate::io::fs_access::save_route( crate::io::fs_access::supported(), self.save_plain_reuse_fs_handle, crate::io::fs_access::handle_ext(tab_id).as_deref(), ext, ); match route { crate::io::fs_access::SaveRoute::WriteInPlace => { // Plain Save on a matching handle — THE point: Ctrl+S // updates the picked file, no dialog at all. The real // name (the user may have renamed it in the picker) // drives the save bookkeeping. let picked_name = crate::io::fs_access::handle_name(tab_id).unwrap_or(filename); Task::batch([ close, self.web_save_capture(tab_id, picked_name, ext.to_string(), version), ]) } crate::io::fs_access::SaveRoute::PickHandle => { // The picker IS the save dialog here: it opens inside // the click's activation window, before any serialize // task starts. Cancelling it cancels the save (same as // a native save-dialog cancel); a picker failure falls // back to the legacy download. let suggested = filename; let pick_suggested = suggested.clone(); Task::batch([ close, Task::perform( async move { crate::io::fs_access::pick_save_handle( tab_id, &pick_suggested, ext, ) .await }, move |picked| Message::WebSaveHandlePicked { tab_id, filename: suggested, ext: ext.to_string(), version, picked, }, ), ]) } // Firefox/Safari (no showSaveFilePicker): untouched legacy // download flow. crate::io::fs_access::SaveRoute::Download => { Task::batch([ close, self.web_save_capture(tab_id, filename, ext.to_string(), version), ]) } } } } /// Web save, destination-known half shared by the legacy download path /// and both File System Access paths: queue the viewport thumbnail /// capture (or skip straight to the serializer when the bounds are /// unavailable), which continues in `on_web_save_screenshot`. #[cfg(target_arch = "wasm32")] fn web_save_capture( &mut self, tab_id: u64, filename: String, ext: String, version: codec::DxfVersion, ) -> Task { // The save is now committed to this tab and this format: mark it // in flight (the web counterpart of native's active_save_jobs — // on_save_file refuses a second save while the slot is held) and // record the intended ext, which the completion half re-checks // against the retained handle before writing. crate::io::fs_access::begin_web_save(tab_id, &ext); let bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); let Some(bounds) = bounds else { return Task::done(Message::WebSaveScreenshot { tab_id, filename, ext, version, bounds: None, screenshot: None, }); }; self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave { tab_id, filename, ext, version, bounds, }); self.thumbnail_capture_clean = true; Task::none() } /// Web SAVE: the showSaveFilePicker promise from the confirm handler /// settled. `Ok(Some(name))` continues the save against the picked /// file's real name (the registry already retains the handle, replacing /// any previous one — Save As re-picks); `Ok(None)` (dismissed picker) /// cancels the save like a native save-dialog cancel; `Err` keeps the /// save alive through the legacy download, with a note that the copy /// was downloaded — after dropping any previously retained handle, /// which was picked for the old format and must not receive the /// fallback bytes. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_handle_picked( &mut self, tab_id: u64, filename: String, ext: String, version: codec::DxfVersion, picked: Result, String>, ) -> Task { match picked { Ok(Some(picked_name)) => { self.web_save_capture(tab_id, picked_name, ext, version) } Ok(None) => { // The picker was the save dialog: dismissing it means no // save — the download fallback must NOT fire (that would // surprise the user with a stray "drawing (1).dwg"). 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(); } Task::none() } Err(error) => { // The picker failed and the download fallback is about to // run with bytes serialized for `ext` — but any handle still // held was picked for (possibly) the OTHER format (Save As // to DXF on a .dwg handle, …). Drop it now so the stale // handle cannot receive the fallback bytes when they land; // the next save re-picks. crate::io::fs_access::drop_handle(tab_id); self.command_line.push_error( crate::tf!( "Save destination picker failed: {error}; downloading a copy instead" ) .as_ref(), ); self.web_save_capture(tab_id, filename, ext, version) } } } /// 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, ) -> Task { 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 { 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 { 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)) } } } /// The OPFS autosave snapshot key for `tab`: the drawing's display name /// (the recent copy's name), or the tab title for untitled tabs — the /// single derivation every web snapshot/remove site must agree on, or a /// remove misses the snapshot a round wrote (and vice versa). #[cfg(target_arch = "wasm32")] pub(super) fn web_autosave_key(tab: &crate::app::document::DocumentTab) -> String { tab.current_path .as_ref() .and_then(|path| path.file_name()) .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| tab.tab_display_name()) } /// 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 { 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) } /// Periodic autosave on the web (SAVETIME): serialize every dirty tab to /// an OPFS snapshot (`io::web_autosave`), the browser counterpart of the /// native `.sv$` copy — best-effort and non-destructive: no download, no /// recents update, dirty flag untouched. Snapshots run serially in one /// round; while it lasts (or a web save's thumbnail capture is pending) /// further ticks are dropped, not queued. Each tab is prepared (clone + /// style sync + dimension bake) once up front, then the DWG/DXF writer /// pass runs on the serializer worker — the UI thread only awaits the /// result between OPFS writes. #[cfg(target_arch = "wasm32")] pub(super) fn on_autosave(&mut self) -> Task { if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() { return Task::none(); } let mut pending = Vec::new(); for i in 0..self.tabs.len() { if !self.tabs[i].dirty { continue; } // Same pre-serialize state sync the web Save flow performs // (on_save_dialog_confirm): stamp the live camera, vport and // sysvars into the document so the snapshot round-trips. 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 = &self.tabs[i]; // Key by the drawing's display name (the recent copy's name), so // the boot scan can compare the two; untitled tabs use the tab // title, which is what Save would download as. let name = Self::web_autosave_key(tab); let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document); let ext = if is_dxf { "dxf" } else { "dwg" }.to_string(); let version = tab.scene.document.version; pending.push(( name, ext, crate::io::prepare_document_for_save(tab.scene.document.clone(), version), )); } if pending.is_empty() { return Task::none(); } self.web_autosave_in_flight = true; Task::perform( async move { let mut failure = None; for (name, ext, prepared) in pending { // Autosave exists to recover geometry, not pretty recents: // no thumbnail capture, just bytes into OPFS. match crate::io::serialize_prepared_document(&prepared, &ext).await { Ok(bytes) => { if let Err(error) = crate::io::web_autosave::snapshot(&name, &bytes, None).await { failure = Some(error); } } Err(error) => failure = Some(error), } } failure.map_or(Ok(()), Err) }, Message::WebAutosaveSnapshotFinished, ) } /// One web autosave round finished. Silent on success; on failure one /// command-line warning until a round succeeds again. Also the drain /// point for snapshot removes a save deferred while the round was /// writing: the round's writes are done, so the keys can be cleared /// for real now (no late write can resurrect them). #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task { self.web_autosave_in_flight = false; let deferred = std::mem::take(&mut self.web_autosave_clear_after); if !deferred.is_empty() { wasm_bindgen_futures::spawn_local(async move { for key in &deferred { let _ = crate::io::web_autosave::remove(key).await; } }); } match result { Ok(()) => self.web_autosave_warned = false, Err(error) => { if !self.web_autosave_warned { self.web_autosave_warned = true; self.command_line.push_warning( crate::tf!("Autosave copy could not be stored: {error}").as_ref(), ); } } } Task::none() } /// The boot scan decided which snapshots are recoverable; queue the /// restore prompt (behind any already-queued startup modal). An empty /// scan — a fresh profile, or everything already cleaned up — must not /// surface an empty "Recover Drawing" modal, so the prompt is only /// queued when there is something to offer. The remembered session list /// is loaded right after the scan: it never opens anything itself, it /// only names what the session had (a line in the restore modal) and /// picks the active tab once restores complete. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosaves_scanned( &mut self, result: Result, String>, ) -> Task { self.web_session = crate::io::web_session::load(); let Ok(mut entries) = result else { // Best-effort feature: no storage access, no prompt. return Task::none(); }; entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms)); self.web_autosave_pending = entries; self.queue_autosave_restore_prompt(); Task::none() } /// Restore the offered snapshot: read the OPFS copy — leaving it in /// place — and hand the bytes to the 50 ms poll's guarded open queue. /// The snapshot is the only copy, so it is deleted only once the /// recovered drawing actually opens (`web_autosave_opened`, on the /// matching `WebFileOpened` success); a parse failure or cancel leaves /// it recoverable for the next boot. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_restore(&mut self) -> Task { let Some(entry) = self.web_autosave_pending.first().cloned() else { self.close_active_modal(); return Task::none(); }; self.web_autosave_pending.remove(0); self.close_active_modal(); let open_name = crate::io::web_autosave::recovered_name(&entry.name); let key = entry.name; // The snapshot key travels with the bytes: the marker (and the // deletion it authorises) is recorded only when the poll actually // starts this open, keyed by that open's id — never by display name. let read_key = key.clone(); Task::perform( async move { crate::io::web_autosave::read(&read_key).await }, move |result| Message::WebAutosaveBytes(open_name, key, result), ) } /// "Restore all": the whole pending list goes through the exact same /// guarded path as a single restore — one read per snapshot, then /// `enqueue_restore` with its own key, and the 50 ms poll opens them /// sequentially. Nothing about consumption changes: each snapshot is /// still deleted only when its own open succeeds (matched by open id), /// and any failure (unreadable, parse error, cancel) keeps that /// snapshot in OPFS for the next boot. The batch counts its settles in /// `web_autosave_restore_all_remaining`; every settle path funnels /// through `queue_next_web_autosave_prompt`, so the countdown — and the /// remembered session's tab selection when it hits zero — needs no /// extra bookkeeping sites. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_restore_all(&mut self) -> Task { // Fewer than two pending: this is just Restore. if self.web_autosave_pending.len() < 2 { return self.on_web_autosave_restore(); } let entries = std::mem::take(&mut self.web_autosave_pending); self.web_autosave_restore_all_remaining = entries.len(); self.close_active_modal(); self.command_line.push_info( crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(), ); let reads = crate::io::web_autosave::restore_all_plan(&entries) .into_iter() .map(|(open_name, key)| { // Same contract as the single restore: the key travels with // the bytes so the deletion marker is recorded by open id. let read_key = key.clone(); Task::perform( async move { crate::io::web_autosave::read(&read_key).await }, move |result| Message::WebAutosaveBytes(open_name, key, result), ) }) .collect::>(); Task::batch(reads) } /// The recovered drawing opened: its autosave snapshot — held back until /// now — can finally be deleted, and the next queued snapshot can be /// offered. The caller already matched the marker to this open's id. #[cfg(target_arch = "wasm32")] pub(super) fn web_autosave_opened(&mut self, open_name: &str) { let Some((key, _)) = self.web_autosave_consumed.take() else { return; }; self.command_line .push_output(crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref()); wasm_bindgen_futures::spawn_local(async move { let _ = crate::io::web_autosave::remove(&key).await; }); self.queue_next_web_autosave_prompt(); } /// Discard the offered snapshot; the prompt stays up for the next one. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_discard(&mut self) -> Task { if let Some(entry) = self.web_autosave_pending.first().cloned() { self.web_autosave_pending.remove(0); wasm_bindgen_futures::spawn_local(async move { let _ = crate::io::web_autosave::remove(&entry.name).await; }); } if self.web_autosave_pending.is_empty() { self.close_active_modal(); } Task::none() } /// Defer the offered snapshot (Escape/✕ on the restore modal): unlike /// Discard the OPFS copy is NOT removed — one stray keystroke must not /// destroy the only recovery copy — it is simply re-offered on the next /// boot. The prompt moves on to the next queued snapshot, if any. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_defer(&mut self) -> Task { if !self.web_autosave_pending.is_empty() { self.web_autosave_pending.remove(0); } if self.web_autosave_pending.is_empty() { self.close_active_modal(); } Task::none() } /// The offered snapshot's bytes arrived (or did not). On success the /// bytes ride the 50 ms poll's guarded open queue — the poll starts an /// open only when the slot is free, so the recovered drawing can /// neither clobber an open in flight nor be lost to one, and the /// snapshot survives until that open lands (`web_autosave_opened`). /// On failure the snapshot stays in OPFS (discardable on a later boot) /// and the prompt moves on to the next one. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_autosave_bytes( &mut self, open_name: String, key: String, result: Result, String>, ) -> Task { match result { Ok(bytes) => { // The next prompt is queued only when this open settles // (`web_autosave_opened` / the failure hook), not here — the // poll has not started the open yet, so queueing now would // show the modal before the recovered drawing loads. // Exception: a rejected enqueue (queue full) means no open // will ever start for these bytes, so the prompt cycle must // continue here or stall for the session. The snapshot // survives in OPFS and is re-offered on the next boot. if !crate::io::web_drop::enqueue_restore(open_name, bytes, key) { self.command_line.push_warning( crate::tf!("Autosave copy could not be queued: too many pending opens") .as_ref(), ); self.queue_next_web_autosave_prompt(); } Task::none() } Err(error) => { // Unreadable snapshot: warn, but neither delete it nor // re-offer it this session — it was already taken out of // `web_autosave_pending`, so the cycle cannot loop on it. // It stays discardable on the next boot. self.command_line .push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref()); self.queue_next_web_autosave_prompt(); Task::none() } } } /// Re-offer the remaining boot snapshots once the current one settled. /// No-op when none are left (or the modal is already queued). This is /// also the single funnel every restore settle runs through (opened, /// failed, cancelled, unreadable, or queue-rejected), so a Restore-all /// batch counts down here — and when its last snapshot settles, the /// remembered session decides which tab ends up active. #[cfg(target_arch = "wasm32")] pub(super) fn queue_next_web_autosave_prompt(&mut self) { if self.web_autosave_restore_all_remaining > 0 { self.web_autosave_restore_all_remaining -= 1; if self.web_autosave_restore_all_remaining == 0 { // Applied at the next message boundary: the last restore's // settle (`web_autosave_opened`) runs BEFORE its own open // inserts the tab, so focusing here could not see it yet. self.web_session_focus_pending = true; } } if !self.web_autosave_pending.is_empty() { self.queue_autosave_restore_prompt(); } } /// A Restore-all batch has fully settled: focus the tab the crashed /// session remembered as active, if it actually came back (or is open /// under its own name). Restored tabs carry the "(recovered)" marker — /// `recovered_name` maps the remembered name onto it idempotently. A /// no-op without remembered memory, or when the remembered tab did not /// return; the last-opened tab then stays active, which is fine. Called /// from the after-message hook (`web_session_focus_pending`), so the /// whole settle batch — including the last tab insertion — is done. #[cfg(target_arch = "wasm32")] pub(super) fn select_remembered_session_tab(&mut self) -> Task { let Some(session) = self.web_session.as_ref() else { return Task::none(); }; let Some(remembered) = session.tabs.get(session.active) else { return Task::none(); }; let recovered = crate::io::web_autosave::recovered_name(remembered); let Some(index) = self.tabs.iter().position(|tab| { let name = tab.tab_display_name(); name == remembered.as_str() || name == recovered }) else { return Task::none(); }; self.active_tab = index; // The active tab changed under the ribbon; re-sync its chips like // every other tab switch does (#21). self.sync_ribbon_layers(); self.sync_ribbon_styles(); self.sync_ribbon_from_selection(); Task::none() } /// Remember the open-tab list (names + active index) in localStorage — /// the web session memory the next boot's restore flow consults. Cheap /// by design: a small `Vec` + index, rewritten in full. Called /// on every save/open/close; the Start tab is not a drawing and is /// skipped, with the active index remapped onto the filtered list. #[cfg(target_arch = "wasm32")] pub(super) fn persist_web_session(&self) { let mut names: Vec = Vec::new(); let mut active = 0; for (index, tab) in self.tabs.iter().enumerate() { if tab.is_start { continue; } if index == self.active_tab { active = names.len(); } names.push(tab.tab_display_name()); } crate::io::web_session::persist(&names, active); } /// 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(&mut 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); } // Web counterpart: drop each open tab's OPFS snapshot, plus any // removes a save deferred while a snapshot round was still writing. // A tab close is not an exit, so this only runs on the explicit exit // path; anything the teardown race leaves behind is caught by the // next boot scan. #[cfg(target_arch = "wasm32")] { let mut keys = std::mem::take(&mut self.web_autosave_clear_after); // An in-flight round may still be writing snapshots right now; // its late writes would land AFTER these removes and resurrect // exactly what was removed, so the per-tab sweep is pointless in // that race — skip it entirely (the boot scan bounds leftovers: // `SNAPSHOT_CAP` eviction discards the oldest overflow). if !self.web_autosave_in_flight { for tab in &self.tabs { keys.push(Self::web_autosave_key(tab)); } } wasm_bindgen_futures::spawn_local(async move { for key in &keys { let _ = crate::io::web_autosave::remove(key).await; } }); } } /// Remove the autosave recovery files, then quit the application. pub(in crate::app) fn exit_app(&mut self) -> Task { 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) { 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::(), d.custom_scale_drawing.trim().parse::(), ) 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::().unwrap_or(0.0)); let y = d .paper_units .to_mm(d.offset_y.trim().parse::().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::().unwrap_or(0.0)), d.paper_units .to_mm(d.offset_y.trim().parse::().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 { 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 || { // SVG shares the plot page with PDF: pick the writer from the // chosen extension. let result = if worker_path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) { crate::io::svg_export::export_svg(&page, &worker_path) } else { crate::io::pdf_export::export_pdf(&page, &worker_path) }; result .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 { 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 || { let result = if worker_path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) { crate::io::svg_export::export_svg(&page, &worker_path) } else { crate::io::pdf_export::export_pdf(&page, &worker_path) }; result .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, 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 { 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 { 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, String> { self.stamp_plot_fields(); let available = self.tabs[self.active_tab].scene.layout_names(); let selected: Vec = 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 { 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 { 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(); } }; self.save_config(); self.close_active_modal(); // The web build has no system printers: render every layout into one // PDF and open it in a browser tab for the browser print dialog. #[cfg(target_arch = "wasm32")] { let stem = self.active_stem("_layouts"); let work = move || { crate::io::print_to_printer::preview_pages_pdf(&stem, &pages) .map(|_| crate::t!("Opened plot preview.").into_owned()) .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; self.run_print_all_work(true, work) } #[cfg(not(target_arch = "wasm32"))] { 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.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(&mut self, background: bool, work: F) -> Task where F: FnOnce() -> Result + Send + 'static, { if background { background_task(crate::t!("Print all"), work, Message::PrintAllFinished) } else { Task::done(Message::PrintAllFinished(work())) } } fn run_plot_work(&mut self, background: bool, reopen_plot: bool, work: F) -> Task where F: FnOnce() -> Result + 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. // A sheet edge below this floor (drawing with tiny extents, or the // 0×0 paper a fresh unsaved drawing carries) cannot be previewed, // printed, or measured meaningfully; the standard-sheet fallback // keeps the output a real page instead of a speck. const MIN_MODEL_SHEET_MM: f64 = 10.0; let standard_sheet = { let (w, h) = selected_sheet; (w.min(h) >= MIN_MODEL_SHEET_MM).then_some((w, h)) }; 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); if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM { // Per-axis: a thin-but-long profile keeps its long // dimension instead of being clipped to the sheet, and // the content is centered on the enlarged page. let (w, h) = standard_sheet.unwrap_or((297.0, 210.0)); let page_w = width.max(w); let page_h = height.max(h); ( page_w, page_h, (page_w - width) * 0.5 - min.x as f64, (page_h - height) * 0.5 - min.y as f64, ) } else { 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 { 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 { 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(); }; // The web build has no system printers: PRINT opens the rendered PDF // in a browser tab, where the browser's own print dialog takes over. #[cfg(target_arch = "wasm32")] { let stem = self.active_stem(""); background_task( crate::t!("Plot preview"), move || { crate::io::print_to_printer::preview_page_pdf(&stem, &page) .map(|_| crate::t!("Opened plot preview.").into_owned()) .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }, |result| Message::BackgroundIoFinished(result, false), ) } #[cfg(not(target_arch = "wasm32"))] { 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) -> Task { 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 = 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")) } /// File stem of the active drawing for default output names, with an /// optional suffix (e.g. "_layouts" for print-all). Falls back to /// "drawing" when the tab has never been saved. fn active_stem(&self, suffix: &str) -> String { self.tabs[self.active_tab] .current_path .as_deref() .and_then(|path| path.file_stem()) .map(|stem| format!("{}{}", stem.to_string_lossy(), suffix)) .unwrap_or_else(|| format!("drawing{suffix}")) } /// 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 { 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 { 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 { // "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 { use crate::ui::window::plot::{ PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, OUT_SVG, 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; self.plot_dialog.file_svg = false; } else if s == OUT_SVG { self.plot_dialog.to_file = true; self.plot_dialog.file_svg = true; } else if s == OUT_DEFAULT { self.plot_dialog.to_file = false; self.plot_dialog.file_svg = false; self.plot_dialog.printer = None; } else { self.plot_dialog.to_file = false; self.plot_dialog.file_svg = 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::() .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 { // `` 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; // / , 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 (`` / ``), 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; d.file_svg = false; 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.file_svg = false; d.printer = None; } crate::io::plot_device::PlotDevice::Printer(name) => { d.to_file = false; d.file_svg = false; d.printer = Some(name); } crate::io::plot_device::PlotDevice::None => { d.to_file = false; d.file_svg = 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 { let d = self.plot_dialog.clone(); // The web dialog only offers PDF output, but a page setup loaded from // the drawing can still seed `to_file = false` — always export there. #[cfg(target_arch = "wasm32")] let to_file = true; #[cfg(not(target_arch = "wasm32"))] let to_file = d.to_file; 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 stem = self.active_stem(""); let work = move || { crate::io::print_to_printer::preview_page_pdf(&stem, &page) .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 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 stem = self.active_stem(""); let work = move || { crate::io::print_to_printer::preview_page_pdf(&stem, &page) .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 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::().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, quality: crate::io::pdf_export::PlotQuality::from_dialog(&d.quality), } } fn dialog_plot_style( &self, d: &crate::ui::window::plot::PlotDialogState, ) -> Option { 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 { self.area_plot_job(self.plot_window?) } pub(in crate::app) fn display_plot_job(&self) -> Option { self.area_plot_job(self.display_plot_window()?) } pub(in crate::app) fn limits_plot_job(&self) -> Option { 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 { 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 { 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 { 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 { 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::() { entry.lineweight = lw; } if let Ok(sc) = self.ps_screening_buf.trim().parse::() { 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::() { entry.lineweight = lw; } if let Ok(sc) = self.ps_screening_buf.trim().parse::() { 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 { 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 crate::ui::window::plot::PlotFlag; use codec::objects::PlotRotation as R; // 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 crate::ui::window::plot::PlotFlag; use codec::objects::{PlotPaperUnits, ScaledType}; 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 crate::ui::window::plot::PlotFlag; 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(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 svg_device_is_session_state_and_stores_as_pdf() { let mut app = app_with_printer_named_sheet(); let _ = app.on_plot_dialog_open(); // The SVG device behaves like file output… let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_SVG.into())); assert!(app.plot_dialog.to_file); assert!(app.plot_dialog.file_svg); // …but switching back to PDF (or a printer) clears it. let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); assert!(app.plot_dialog.to_file); assert!(!app.plot_dialog.file_svg); // The drawing never learns about SVG: page setups only know printers // and PDF drivers, so an SVG plot stores the PDF driver spelling. let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_SVG.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"); } #[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" ); } /// `` 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)); // `` 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 { 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}"); } } impl OpenCADStudio { /// Web: one warning lists every unresolved xref and the way to fix it — /// dropping the files on the page registers their bytes, and reopening /// the drawing (or XRELOAD) then resolves them. #[cfg(target_arch = "wasm32")] pub(in crate::app) fn warn_unresolved_xrefs(&mut self, infos: &[crate::io::xref::XrefInfo]) { let unresolved = crate::io::xref::unresolved_xref_names(infos); if unresolved.is_empty() { return; } let names = unresolved.join(", "); self.command_line.push_warning( crate::tf!( "XREF Unresolved external references: {names} — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD." ) .as_ref(), ); } }