//! `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::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; pub(super) fn background_task(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 (tx, rx) = iced::futures::channel::oneshot::channel(); std::thread::spawn(move || { let _ = tx.send(work()); }); Task::perform( async move { rx.await.expect("background export worker dropped") }, map, ) } #[cfg(target_arch = "wasm32")] { Task::perform(async move { work() }, map) } } 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 acadrust::entities::ViewportRenderMode as Mode; match d.shade.as_str() { "2D Wireframe" => Some(Mode::Wireframe2D), "3D Wireframe" => Some(Mode::Wireframe3D), "Hidden Line" => Some(Mode::HiddenLine), "Flat Shaded" => Some(Mode::FlatShaded), "Gouraud Shaded" => Some(Mode::GouraudShaded), "Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges), "Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges), _ => None, } } #[cfg(not(target_arch = "wasm32"))] fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { match ( std::fs::canonicalize(left), std::fs::canonicalize(right), ) { (Ok(left), Ok(right)) => left == right, _ => left == right, } } type LayoutPlotParams = ( std::sync::Arc>, Vec, Vec, crate::io::pdf_export::PlotGroupSplits, f64, f64, f64, f64, i32, f32, Option<(f32, f32, f32, f32)>, ); type ClippedPlotParams = ( Vec, Vec, Vec, crate::io::pdf_export::PlotGroupSplits, f64, f64, f64, f64, i32, f32, Option<(f32, f32, f32, f32)>, ); fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let orientation = if d.orientation == "Portrait" { Orientation::Portrait } else { Orientation::Landscape }; let standard = match d.paper.as_str() { "A3" => Some(PaperSize::A3), "A2" => Some(PaperSize::A2), "A1" => Some(PaperSize::A1), "A0" => Some(PaperSize::A0), "A4" => Some(PaperSize::A4), "Letter" => Some(PaperSize::Letter), "Legal" => Some(PaperSize::Legal), "Tabloid" => Some(PaperSize::Tabloid), _ => None, }; if let Some(paper) = standard { return sheet_mm(paper, orientation); } let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0); let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0); match orientation { Orientation::Portrait => (short, long), Orientation::Landscape => (long, short), } } fn plot_content_extents( wires: &[crate::io::pdf_export::PlotWire], hatches: &[crate::scene::model::hatch_model::HatchModel], wipeouts: &[crate::scene::model::hatch_model::HatchModel], ) -> Option<(f64, f64, f64, f64)> { 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 { 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); } } (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) } fn plot_scene_content( scene: &crate::scene::Scene, paper_space_last: bool, render_mode_override: Option, ) -> ( std::sync::Arc>, Vec, Vec, crate::io::pdf_export::PlotGroupSplits, ) { 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, acadrust::EntityType::Viewport(viewport) if !crate::scene::Scene::is_sheet_viewport( &scene.document, viewport, ) ) })) }); model_wires.retain(|wire| wire.plot_visible); let with_depth = |wires: Vec| { let depths = scene.plot_wire_depths(&wires); wires .into_iter() .zip(depths) .map(|(wire, draw_depth)| 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(); if scene.current_layout == "Model" { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), }; return ( std::sync::Arc::new(paper_wires), paper_hatches, paper_wipeouts, splits, ); } let (mut model_pattern_wires, model_hatches, model_wipeouts) = scene.viewport_plot_fills(); model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires .into_iter() .map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth }) .collect::>(); let (wires, hatches, wipeouts, 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(), }; let mut wires = model_wires; let mut hatches = model_hatches; let mut wipeouts = model_wipeouts; wires.extend(model_pattern_wires); wires.extend(paper_wires); hatches.extend(paper_hatches); wipeouts.extend(paper_wipeouts); (wires, hatches, wipeouts, splits) } else { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), }; let mut wires = paper_wires; let mut hatches = paper_hatches; let mut wipeouts = paper_wipeouts; wires.extend(model_wires); wires.extend(model_pattern_wires); hatches.extend(model_hatches); wipeouts.extend(model_wipeouts); (wires, hatches, wipeouts, splits) }; (std::sync::Arc::new(wires), hatches, wipeouts, splits) } impl OpenCADStudio { /// Persist exact ACIS bodies and kernel-derived edge caches before saving. fn sync_solid_models_for_save(&mut self, i: usize) { use acadrust::EntityType; let scene = &mut self.tabs[i].scene; let targets: Vec<(acadrust::Handle, bool, bool)> = scene .document .entities() .filter_map(|entity| { let EntityType::Solid3D(solid) = entity else { return None; }; let h = solid.common.handle; let needs_acis = !solid.acis_data.has_data(); let needs_wires = solid.wires.is_empty(); (needs_acis || needs_wires).then_some((h, needs_acis, needs_wires)) }) .collect(); for (h, needs_acis, needs_wires) in targets { let body = scene.solid_models.get(&h); let sat = needs_acis .then(|| { body.and_then(crate::scene::convert::acis_export::solid_to_sat) }) .flatten(); let wires = needs_wires.then(|| { if let Some(body) = body { return crate::scene::model::solid_model::edge_wires(body); } let Some(mesh) = scene.meshes.get(&h).or_else(|| scene.block_meshes.get(&h)) else { return Vec::new(); }; mesh.edge_verts .chunks_exact(2) .enumerate() .map(|(index, points)| { let first_low = mesh .edge_verts_low .get(index * 2) .copied() .unwrap_or([0.0; 3]); let second_low = mesh .edge_verts_low .get(index * 2 + 1) .copied() .unwrap_or([0.0; 3]); acadrust::entities::Wire::from_points(vec![ acadrust::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, ), acadrust::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; } } } } /// Snapshot the persisted UI preferences from live state. pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings { crate::app::settings::UserSettings { dyn_input: self.dyn_input, polar: self.polar_mode, polar_increment_deg: self.polar_increment_deg, zoom_wheel_reversed: self.zoom_wheel_reversed, zoom_factor: self.zoom_factor, cursor_size: self.cursor_size, pick_box: self.pick_box, double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, grip_object_limit: self.grip_object_limit, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, isometric_drafting: self.isometric_drafting, iso_plane: self.iso_plane, snap_angle_deg: self.snap_angle_deg, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, donation_prompt_version: self.donation_prompt_version.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, savetime_min: self.savetime_min, 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, bg_color: None, paper_bg_color: None, 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, ), block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), } } /// Apply restored preferences to live state. pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) { self.dyn_input = s.dyn_input; self.polar_mode = s.polar; self.polar_increment_deg = s.polar_increment_deg; self.zoom_wheel_reversed = s.zoom_wheel_reversed; self.zoom_factor = s.zoom_factor.clamp(3, 100); self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; self.crosshair_color_input = s .crosshair_color .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200); self.isometric_drafting = s.isometric_drafting; self.iso_plane = s.iso_plane; self.snap_angle_deg = if s.snap_angle_deg.is_finite() { s.snap_angle_deg.rem_euclid(360.0) } else { 0.0 }; // 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.donation_prompt_version = s.donation_prompt_version.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.savetime_min = s.savetime_min; 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; // Legacy settings.bg_color / paper_bg_color are superseded by model_space config. 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, ); // 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(); // 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) { 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 }, annotation_auto_scale: self.annotation_auto_scale, ribbon: crate::app::config::RibbonConfig { collapse: self.ribbon.collapse_mode(), }, plot: self.plot_dialog.clone(), shortcuts: crate::app::config::ShortcutConfig { bindings: self .shortcut_bindings .iter() .map(|(key, command)| (key.clone(), command.clone())) .collect(), }, model_space: self.model_space.clone(), } } /// Distribute a loaded config into live app state (called once at startup). pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) { self.apply_settings(&cfg.settings); self.ui_theme = cfg.theme.clone(); self.active_theme = self.ui_theme.to_iced(); self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.model_space = cfg.model_space; self.model_bg_input = self .model_space .custom_bg .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.paper_bg_input = self .model_space .custom_paper_bg .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.desk_bg_input = self .model_space .custom_desk_bg .map(crate::app::config::rgb_to_hex) .unwrap_or_default(); self.sync_model_space_theme(false); self.recent_files = cfg .recent .files .iter() .map(std::path::PathBuf::from) .collect(); self.recent_limit = cfg .recent .limit .clamp(crate::app::recent::RECENT_MIN, crate::app::recent::RECENT_MAX); self.recent_files.truncate(self.recent_limit); self.recent_limit_input = self.recent_limit.to_string(); // Thumbnails are decoded by a background task queued at boot // (`refresh_recent_thumbs`) — never here on the boot path. self.start_section = cfg.start.section; self.statusbar_config = cfg.statusbar; let mut dock = cfg.dock; dock.ensure_settings(); self.dock = dock; self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect(); self.shortcut_bindings .entry("F5".to_string()) .or_insert_with(|| "ISOPLANE".to_string()); } /// Write the config only when it changed since the last write, so a toggle /// persists immediately without thrashing native or browser storage. pub(in crate::app) fn save_config(&mut self) { let cur = self.current_config(); if self.last_saved_config.as_ref() != Some(&cur) { cur.save(); self.last_saved_config = Some(cur); } } /// Back-compat name for the many "a preference changed, persist it" sites. pub(in crate::app) fn persist_settings_if_changed(&mut self) { // Keep resize feedback live without writing settings on every pointer // move. The release message saves the final height once. if !self.command_history_resizing { self.save_config(); } } /// Sync the active theme and model space configuration into all open tabs /// and the application default background settings. pub(in crate::app) fn sync_model_space_theme(&mut self, geometry_changed: bool) { let model_bg = self.model_space.resolve_model_bg(&self.active_theme); let paper_bg = self.model_space.resolve_paper_bg(); let sel_color = self.model_space.resolve_selection_color(); let sel_effect = self.model_space.selection_effect; self.default_bg_color = Some(model_bg); self.default_paper_bg_color = Some(paper_bg); for tab in &mut self.tabs { let old_bg = tab.scene.bg_color; let old_paper = tab.scene.paper_bg_color; let old_sel_color = tab.scene.selection_color; let old_sel_effect = tab.scene.selection_effect; tab.scene.bg_color = model_bg; tab.scene.paper_bg_color = paper_bg; tab.scene.selection_color = sel_color; tab.scene.selection_effect = sel_effect; tab.bg_color = Some(model_bg); tab.paper_bg_color = Some(paper_bg); if old_sel_color != sel_color || old_sel_effect != sel_effect { tab.scene.selection_generation = tab.scene.selection_generation.wrapping_add(1); } if geometry_changed || old_bg != model_bg || old_paper != paper_bg { tab.scene.recolor_meshes(); tab.scene.bump_geometry(); } tab.scene.request_refresh(crate::scene::ViewportRefreshScope::All); } } /// Record that the one-time default-association prompt has been answered and /// flush it to disk, so the dialog never reappears on later launches. pub(in crate::app) fn mark_assoc_prompted(&mut self) { self.default_assoc_prompted = true; self.persist_settings_if_changed(); } pub(super) fn on_open_file(&mut self) -> Task { // Native: pick a path, then load on a worker thread. Web: the // browser hands back bytes, so pick + parse in one step and feed // the shared `FileOpened` handler directly. #[cfg(not(target_arch = "wasm32"))] { Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) } #[cfg(target_arch = "wasm32")] { // `FileOpened` only installs the result when an open is in // progress, so mark one. The browser picker + parse happen // inside `pick_and_load_web`; the real name is unknown until // then, so show a generic label meanwhile. 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: "Opening…".into(), source_path: None, size_bytes: 0, state: state.clone(), started: Instant::now(), recovery_error: None, recovery_read_stats: None, recovery_bytes: None, }); Task::perform(crate::io::pick_and_load_web(state), move |outcome| { Message::WebFileOpened(open_id, outcome) }) } } 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 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 version = self.tabs[i].scene.document.version; let snapshot = self.tabs[i].scene.document.clone(); 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.refresh_native_edit_guard_after_save( i, &path, path_changed, destination_lease, ); self.tabs[i].dirty = false; if set_current_path { self.tabs[i].recovery_save_as_required = false; } let _ = std::fs::remove_file(previous_autosave); Ok(()) } #[cfg(not(target_arch = "wasm32"))] fn retry_native_edit_guard( &mut self, i: usize, path: &std::path::Path, ) -> Result<(), String> { if let Some(lease) = self.tabs[i].edit_lease.as_mut() { match lease.refresh_drawing_lock(path) { Ok(()) => { let warning = lease.platform_warning().map(str::to_owned); self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { self.command_line.push_info(crate::tf!( "{warning}. External-change checks remain active." ).as_ref()); } return Ok(()); } Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => { return Err(error); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lock_conflict = false; self.command_line.push_info(crate::tf!( "{error}. External-change checks remain active." ).as_ref()); return Ok(()); } } } match crate::io::edit_lock::EditLease::acquire(path) { Ok(lease) => { let warning = lease.platform_warning().map(str::to_owned); self.tabs[i].edit_lease = Some(lease); self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { self.command_line.push_info(crate::tf!( "{warning}. External-change checks remain active." ).as_ref()); } Ok(()) } Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => Err(error), Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lock_conflict = false; self.command_line.push_info(crate::tf!( "{error}. External-change checks remain active." ).as_ref()); Ok(()) } } } pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::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(); } 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 == acadrust::notification::NotificationType::Error }); let reference_recovered = caches .xrefs .iter() .any(|item| item.status == crate::io::xref::XrefStatus::Recovered); let reference_failed = caches .xrefs .iter() .any(|item| item.status == crate::io::xref::XrefStatus::Failed); let document_repaired = parser_errors_recovered || reference_recovered || caches.corrupt_dropped > 0 || caches.xref_dropped > 0; let recovery_needed = document_repaired || reference_failed; let total_ms = open_started .map(|started| started.elapsed().as_millis() as u32) .unwrap_or(0); self.command_line .push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref()); if caches.corrupt_dropped > 0 { self.command_line.push_error(crate::tf!( "Warning: {} corrupt entities dropped (parser junk — bad normals / counts)", caches.corrupt_dropped ).as_ref()); } if caches.xref_dropped > 0 { self.command_line.push_error(crate::tf!( "Warning: {} corrupt xref entities dropped", caches.xref_dropped ).as_ref()); } for info in &caches.xrefs { match info.status { crate::io::xref::XrefStatus::Loaded => { self.command_line .push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref()); } crate::io::xref::XrefStatus::Recovered => { self.command_line.push_error(crate::tf!( "XREF Recovered with warnings: \"{}\"", info.name ).as_ref()); } crate::io::xref::XrefStatus::NotFound => { self.command_line.push_error(crate::tf!( "XREF Not found: \"{}\" ({})", info.name, info.path ).as_ref()); } crate::io::xref::XrefStatus::Failed => { self.command_line.push_error(crate::tf!( "XREF Recovery failed: \"{}\" ({})", info.name, info.path ).as_ref()); } crate::io::xref::XrefStatus::Unloaded => { self.command_line .push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref()); } } } #[cfg(not(target_arch = "wasm32"))] let thumbs_task = self.push_recent(path.clone()); #[cfg(target_arch = "wasm32")] let thumbs_task = Task::none(); let current_is_empty = { let t = &self.tabs[self.active_tab]; !t.is_start && t.current_path.is_none() && !t.dirty && self.tabs[self.active_tab].scene.document.entities().count() == 0 }; let i = if current_is_empty { self.active_tab } else { self.tab_counter += 1; let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter); self.tabs.push(new_tab); let idx = self.tabs.len() - 1; self.active_tab = idx; self.apply_display_defaults(idx); idx }; 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].active_layer = self.tabs[i] .scene .document .header .current_layer_name .clone(); // A file saved without the built-in Standard styles (foreign // or damaged) gets them re-seeded so nothing dangles (#366). crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); // Follow the file's saved current UCS from the moment it opens. self.tabs[i].adopt_active_ucs_from_header(); // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP // ($OSMODE) so each file keeps its own state. self.adopt_header_sysvars(i); // Route shared CJK ideographs to the language matching this // drawing's code page (web per-language font split). Drop the // glyph cache if it changed so Han re-resolves to the new // language's font; geometry is (re)built below regardless. (#141) if crate::scene::text::web_font::set_cjk_lang_from_codepage( &self.tabs[i].scene.document.header.code_page, ) { crate::scene::text::ttf_glyph::clear_fallback_cache(); } // Current model-space annotation scale comes from the drawing's // CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into // drawing units as well: metric annotation sizes are paper millimetres // and imperial annotation sizes are paper inches. // Current model-space annotation scale comes from the drawing's // CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into // the drawing's INSUNITS as well, so e.g. a metre drawing uses // 0.001 model units for 1 mm of paper at 1:1. let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value; let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor(); self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 { ((1.0 / cannoscale_value) / unit_factor) as f32 } else { (1.0 / unit_factor) as f32 }; // Open-time breakdown so regressions are visible immediately. // `total` is wall time from the Open click to here (post-xref, // pre-first-frame); the phase figures are the background-thread // parse/purge/cache spans plus the UI-thread xref resolve. self.command_line.push_info(crate::tf!( " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms ).as_ref()); // Caches were built on the background thread inside open_path(). self.tabs[i].scene.local_extent_max = caches.local_extent_max; self.tabs[i].scene.local_center = caches.local_center; self.tabs[i].scene.hatches = caches.hatches; self.tabs[i].scene.images = caches.images; self.tabs[i].scene.meshes = caches.meshes; self.tabs[i].scene.block_meshes = caches.block_meshes; self.tabs[i].scene.object_data_cache = caches.object_data; let prepared_geometry = caches.prepared_geometry.take(); // Invalidate the wire cache so the new document is tessellated. self.tabs[i].scene.bump_geometry(); if let Some(prepared) = prepared_geometry { self.tabs[i].scene.install_prepared_open_geometry(prepared); } // Pay for the interaction index here, not on the first hover. self.tabs[i].scene.warm_interaction_caches(); self.tabs[i] .scene .replace_selection(rustc_hash::FxHashSet::default()); self.tabs[i].scene.preview_wires = vec![]; // Reopen in whichever space the file was saved in — the CTAB // tab name when recorded, else the $TILEMODE model/paper flag — // instead of always landing in Model. { let names = self.tabs[i].scene.layout_names(); let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document) .and_then(|n| { names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned() }); self.tabs[i].scene.current_layout = match saved { Some(n) => n, None if self.tabs[i].scene.document.header.show_model_space => { "Model".to_string() } // Paper was active but no CTAB — fall back to the first // paper layout (names[0] is always "Model"). None => names .into_iter() .nth(1) .unwrap_or_else(|| "Model".to_string()), }; self.tabs[i].scene.load_current_layout_state(); self.tabs[i].refresh_active_ucs(); } // Object isolation is session-only. A newly opened drawing must // not inherit the previous tab's filter, and persisted entity // visibility remains independent (not an isolation session). self.tabs[i].scene.reset_transient_visibility(); crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document); self.tabs[i].properties = PropertiesPanel::empty(); // Seed the current table / multileader style from the file's // header so the ✓ marks the right one (text/dim/mline come from // the document header directly). DXF provides these via // $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard". self.ribbon.active_table_style = self.tabs[i] .scene .document .header .current_table_style_name .clone(); self.tabs[i].active_mleader_style = self.tabs[i] .scene .document .header .current_mleader_style_name .clone(); let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); self.tabs[i] .layers .sync_with_viewports(&doc_layers, vp_info); self.sync_ribbon_layers(); // Load the Annotate-ribbon style dropdowns (text / dimension / // multileader / table) from the opened document instead of // leaving them on the hard-coded "Standard" default. self.sync_ribbon_styles(); // Reset the Home-ribbon Color / Linetype / Lineweight chips // to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT // defaults (or to ByLayer when the file leaves them empty). // Without this they stick to whatever the prior tab had // selected — see #21. self.sync_ribbon_from_selection(); self.tabs[i].scene.restore_saved_camera(); // Grid/snap are per-drawing view settings — adopt the opened // file's active viewport state rather than a global preference. self.adopt_view_display(i); self.sync_render_mode_to_active_tile(i); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; self.tabs[i].dirty = document_repaired; self.tabs[i].recovery_save_as_required = document_repaired; self.tabs[i].history = crate::app::document::HistoryState::default(); self.refresh_properties(); #[cfg(not(target_arch = "wasm32"))] let interaction_task = { let wires = self.tabs[i].scene.hit_test_wires(); let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; self.prepare_interaction_index_task(i, wires, screen_height) .unwrap_or_else(Task::none) }; #[cfg(target_arch = "wasm32")] let interaction_task = Task::none(); if let Some(opening) = &self.opening { opening .state .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); } self.opening.take(); let pending_open_task = if let Some(report) = recovery_report { self.recovery_report = Some(report); self.active_modal = Some(crate::app::ModalKind::Recovery); Task::none() } else { self.drain_pending_open() }; Task::batch([thumbs_task, pending_open_task, interaction_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.clone(); let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); let worker_name = block_name.clone(); let worker_path = path.clone(); background_task( move || { let document = if worker_name == "*" { crate::modules::insert::wblock::extract_entities_to_doc( &document, &handles, ) } else { crate::modules::insert::wblock::extract_block_to_doc( &document, &worker_name) } .map_err(|e| e.to_string())?; crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) }, move |result| Message::WblockWriteFinished(block_name, path, result),) } pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task { // 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( 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())?; std::fs::write(&worker_path, bytes).map_err(|e| e.to_string()) }, 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( 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())?; std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string()) }, 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( move || { let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0]) .ok_or_else(|| "no usable geometry in file".to_string()) }, move |result| Message::ObjImportFinished(tab_id, path, result),) } fn sync_view_state_for_save(&mut self, i: usize) { self.sync_vport_display(i); if self.tabs[i].active_block_edit.is_none() { self.tabs[i].scene.sync_camera_to_document(); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; } } #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn prepare_native_save(&mut self, i: usize) { self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; self.sync_solid_models_for_save(i); } #[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: acadrust::DxfVersion, bounds: Option, screenshot: Option, ) -> Task { self.thumbnail_capture_clean = false; let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { return Task::none(); }; let preview = screenshot.as_ref().and_then(|screenshot| { bounds.and_then(|bounds| { crate::io::thumbnail::from_screenshot( screenshot, bounds, version >= acadrust::DxfVersion::AC1027, ) }) }); if let Some(preview) = preview { self.tabs[i].scene.document.preview = Some(preview); } let mut recent_task = Task::none(); let saved = match crate::io::save_to_bytes( &self.tabs[i].scene.document, &ext, version, ) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); let cache_name = std::path::Path::new(&filename) .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| filename.clone()); let path = std::path::PathBuf::from(cache_name); self.tabs[i].current_path = Some(path.clone()); self.tabs[i].scene.document.version = version; self.tabs[i].dirty = false; self.tabs[i].recovery_save_as_required = false; recent_task = Task::perform( async move { crate::io::web_recent::store(&path.to_string_lossy(), &bytes) .await .map(|_| path) }, Message::WebRecentStored, ); self.command_line .push_output(crate::tf!("Saved: {filename}").as_ref()); true } Err(error) => { self.command_line .push_error(crate::tf!("Save failed: {error}").as_ref()); false } }; if self.save_dialog_for_unsaved { if saved { if let Some(crate::app::PendingClose::Tab(index)) = self.pending_close.take() { let continuation = self.update(Message::TabClose(index)); let rest = self.continue_tab_close_queue(); return Task::batch([recent_task, continuation, rest]); } } else if self.pending_close.is_some() { let retry = self.open_unsaved_dialog_window(); return Task::batch([recent_task, retry]); } } recent_task } #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn queue_native_save( &mut self, i: usize, path: std::path::PathBuf, version: acadrust::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 >= acadrust::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 snapshot = self.tabs[i].scene.document.clone(); 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(i)); } 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(i)); 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 { 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; } } 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(i))); 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(i)); 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 => { Some(crate::app::PendingClose::Tab(tab_idx)) } 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")] { 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; 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 { ("DWG Files", "dwg") }; let close = self.close_save_dialog_window(); let pick = Task::perform( async move { let mut dlg = crate::sys::file_dialog() .set_title(crate::t!("Save Drawing As").as_ref()) .set_file_name(default_name) .add_filter(filter_label, &[filter_ext]); if let Some(dir) = seed_dir { dlg = dlg.set_directory(dir); } dlg.save_file().await.map(|h| crate::sys::handle_path(&h)) }, Message::SaveDialogPathPicked, ); Task::batch([close, pick]) } // Web: no filesystem — serialize and hand the browser a download. #[cfg(target_arch = "wasm32")] { let close = self.close_save_dialog_window(); self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); self.sync_solid_models_for_save(i); let tab_id = self.tabs[i].id; let bounds = crate::ui::wrap_bar::dropdown_bounds( crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, ); let Some(bounds) = bounds else { return Task::batch([ close, Task::done(Message::WebSaveScreenshot { tab_id, filename, ext: ext.to_string(), version, bounds: None, screenshot: None, }), ]); }; self.pending_web_thumbnail_save = Some( crate::app::PendingWebThumbnailSave { tab_id, filename, ext: ext.to_string(), version, bounds, }, ); self.thumbnail_capture_clean = true; close } } /// Native: a destination path came back from the OS save dialog — write the /// file there in the chosen version. `None` means the user cancelled. #[cfg(not(target_arch = "wasm32"))] pub(super) fn on_save_dialog_path_picked( &mut self, picked: Option, ) -> 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 )) } } } /// 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_native_save(i); let version = self.tabs[i].scene.document.version; let target = self.autosave_target(i); tasks.push(self.queue_native_save( i, target, version, crate::app::SavePurpose::Autosave, crate::app::SaveContinuation::None, false, false, )); } Task::batch(tasks) } #[cfg(target_arch = "wasm32")] pub(super) fn on_autosave(&mut self) -> Task { Task::none() } /// Delete the `.sv$` autosave recovery files for all open drawings. They /// exist only to survive a crash, so a clean save or exit removes them. pub(in crate::app) fn cleanup_autosaves(&self) { #[cfg(not(target_arch = "wasm32"))] for i in 0..self.tabs.len() { let _ = std::fs::remove_file(self.autosave_target(i)); } } /// Remove the autosave recovery files, then quit the application. pub(in crate::app) fn exit_app(&self) -> Task { self.cleanup_autosaves(); iced::exit() } /// Write the given plot page settings 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. #[allow(clippy::too_many_arguments)] pub(super) fn apply_plot_page_settings( &mut self, w: f64, h: f64, plot_area: &str, center: bool, offset_x: f64, offset_y: f64, rotation: i16, ) { let i = self.active_tab; let dialog = self.plot_dialog.clone(); let layout_name = self.tabs[i].scene.current_layout.clone(); let plot_window = self.plot_window; if layout_name != "Model" { let w: f64 = w.max(1.0); let h: f64 = h.max(1.0); use acadrust::objects::{ PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; let mut ps = self .plot_setup_template .clone() .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) .unwrap_or_else(|| PlotSettings::new("")); ps.paper_width = w; ps.paper_height = h; ps.paper_size = dialog.paper.clone(); ps.plot_type = match plot_area { "Window" => PlotType::Window, "Display" => PlotType::LastScreenDisplay, "Extents" => PlotType::Extents, "Limits" => PlotType::Limits, area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Layout, }; ps.plot_view_name = plot_area .strip_prefix("View: ") .unwrap_or("") .to_string(); if plot_area == "Window" { if let Some((x0, y0, x1, y1)) = plot_window { ps.set_plot_window(x0, y0, x1, y1); } } ps.flags.plot_centered = center && plot_area != "Layout"; ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; ps.rotation = match rotation { 90 => PlotRotation::Degrees90, 180 => PlotRotation::Degrees180, 270 => PlotRotation::Degrees270, _ => PlotRotation::None, }; if dialog.fit_to_paper && plot_area != "Layout" { ps.set_scale_to_fit(); } else if plot_area == "Layout" { ps.set_standard_scale(ScaledType::OneToOne); ps.standard_scale_factor = 1.0; } else { let factor = plot_dialog_scale_factor(&dialog); ps.scale_type = ScaledType::CustomScale; ps.scale_numerator = factor; ps.scale_denominator = 1.0; ps.standard_scale_factor = factor; ps.flags.use_standard_scale = false; } ps.printer_name = if dialog.to_file { crate::ui::window::plot::OUT_PDF.into() } else { dialog.printer.clone().unwrap_or_default() }; ps.current_style_sheet = dialog.style_name.clone(); ps.flags.scale_lineweights = dialog.scale_lw; ps.flags.print_lineweights = dialog.lineweights; ps.flags.plot_plot_styles = dialog.apply_plot_styles && !dialog.style_name.is_empty(); ps.flags.show_plot_styles = dialog.show_plot_styles && !dialog.style_name.is_empty(); ps.flags.draw_viewports_first = dialog.paperspace_last; ps.flags.plot_hidden = dialog.shade == "Hidden Line"; ps.shade_plot_mode = match dialog.shade.as_str() { "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, "Hidden Line" => ShadePlotMode::Hidden, "As displayed" => ShadePlotMode::AsDisplayed, _ => ShadePlotMode::Rendered, }; ps.shade_plot_resolution = match dialog.quality.as_str() { "Low" => ShadePlotResolutionLevel::Draft, "High" => ShadePlotResolutionLevel::Presentation, _ => ShadePlotResolutionLevel::Normal, }; ps.shade_plot_dpi = 300; self.tabs[i] .scene .set_layout_plot_settings(&layout_name, &ps); for obj in self.tabs[i].scene.document.objects.values_mut() { if let acadrust::objects::ObjectType::Layout(layout) = obj { if layout.name == layout_name { layout.min_limits = (0.0, 0.0); layout.max_limits = (w, h); layout.min_extents = (0.0, 0.0, 0.0); layout.max_extents = (w, h, 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(); if let Some(settings) = self.tabs[i].scene.effective_plot_settings() { self.load_plotsettings_into_dialog(&settings); } } let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = self.direct_plot_params() else { self.command_line .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); let work = move || { crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, &worker_path, plot_style.as_ref(), render_options, ) .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((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); self.close_active_modal(); let work = move || { crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, &worker_path, plot_style.as_ref(), render_options, ) .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; let previous_setup = self.plot_setup_template.clone(); let task = self.on_plot_dialog_open(); 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_plot_setup_prev = Some(previous_setup); self.print_all_options = true; self.plot_dialog.paper_space = true; self.plot_dialog.area = "Layout".into(); task } fn print_all_pages(&mut self) -> Result, String> { 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.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 plot_style = self.dialog_plot_style(&dialog); let params = 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 ( wires, hatches, wipeouts, group_splits, paper_w, paper_h, offset_x, offset_y, rotation_deg, scale, clip, ) = if dialog.area == "Layout" { self.layout_plot_params_for("Layout") } else { let ( wires, hatches, wipeouts, group_splits, paper_w, paper_h, offset_x, offset_y, rotation_deg, scale, clip, ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; ( std::sync::Arc::new(wires), hatches, wipeouts, group_splits, paper_w, paper_h, offset_x, offset_y, rotation_deg, scale, clip, ) }; pages.push(crate::io::pdf_export::PdfPageInput { wires, hatches, wipeouts, paper_w, paper_h, offset_x, offset_y, rotation_deg, scale, clip, options: Self::pdf_plot_options(&dialog, group_splits), plot_style, }); } 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(); self.save_config(); self.close_active_modal(); let work = move || { crate::io::pdf_export::export_pdf_pages( &pages, &worker_path, None, ) .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, work) } pub(super) fn on_print_all_print(&mut self) -> Task { #[cfg(target_arch = "wasm32")] { self.command_line.push_error( crate::t!("Printing is not available in the web version.").as_ref(), ); Task::none() } #[cfg(not(target_arch = "wasm32"))] { let dialog = self.plot_dialog.clone(); if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", dialog.style_name ).as_ref()); return Task::none(); } let pages = match self.print_all_pages() { Ok(pages) => pages, Err(error) => { self.command_line.push_error(&error); return Task::none(); } }; let options = self.plot_print_options(&dialog, Default::default()); let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); self.command_line.push_info( crate::t!("Sending selected layouts to the system printer…").as_ref(), ); let work = move || { crate::io::pdf_export::export_pdf_pages( &pages, &temp_path, None, ) .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(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(work, move |result| { Message::BackgroundIoFinished(result, reopen_plot) }) } else { let result = work(); self.report_plot_result(result, reopen_plot); Task::none() } } /// Build the render inputs and page geometry for a full-layout plot: wires /// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size, /// draw-origin offset, rotation, unit scale, and optional clip. Shared by /// PDF export, preview, and printer output so all three render identically. pub(super) fn layout_plot_params(&self) -> LayoutPlotParams { self.layout_plot_params_for(&self.plot_dialog.area) } fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { 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 (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. let wires = if paper_space { group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())] .iter() .filter(|wire| wire.name != "paper_printable_area") .count(); std::sync::Arc::new( source_wires .iter() .filter(|wire| wire.name != "paper_printable_area") .cloned() .collect(), ) } else { source_wires }; 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 = self .plot_dialog .offset_x .parse::() .unwrap_or(0.0); let origin_y = self .plot_dialog .offset_y .parse::() .unwrap_or(0.0); let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = plot_content_extents(&wires, &hatches, &wipeouts) .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 ( wires, hatches, wipeouts, group_splits, page_w, page_h, offset_x, offset_y, rotation, scale as f32, clip, ); } // Model space keeps its established extents + 5% margin behaviour. let (page_w, page_h, offset_x, offset_y) = if let Some((min, max)) = scene.model_space_extents() { let margin = 1.05_f64; let width = ((max.x - min.x) as f64 * margin).max(1.0); let height = ((max.y - min.y) as f64 * margin).max(1.0); let pad_x = (width - (max.x - min.x) as f64) * 0.5; let pad_y = (height - (max.y - min.y) as f64) * 0.5; ( width, height, -(min.x as f64) + pad_x, -(min.y as f64) + pad_y, ) } else { (297.0, 210.0, 0.0, 0.0) }; ( wires, hatches, wipeouts, group_splits, page_w, page_h, offset_x, offset_y, 0, 1.0, None, ) } /// 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_params(&self) -> Option { if self.tabs[self.active_tab].scene.current_layout != "Model" { return Some(self.layout_plot_params_for("Layout")); } let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.extents_plot_job()?; if wires.is_empty() && hatches.is_empty() && wipeouts.is_empty() { return None; } Some(( std::sync::Arc::new(wires), hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip, )) } pub(super) fn on_print_to_printer(&mut self) -> Task { let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = self.direct_plot_params() else { self.command_line .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); let options = self.plot_print_options(&self.plot_dialog, group_splits); self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); background_task( move || { iced::futures::executor::block_on( crate::io::print_to_printer::print_wires_with( wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, plot_style, 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")) } /// 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_sizes::Orientation; let previous = self.plot_dialog.clone(); let scales: Vec<(String, f64)> = self.tabs[self.active_tab] .scene .scale_list() .into_iter() .map(|(name, _, factor)| (name, factor)) .collect(); let one_to_one = scale_name_for_factor(&scales, 1.0) .or_else(|| scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; let plot_views = self.tabs[self.active_tab] .scene .document .views .iter() .filter(|view| view.paper_space == paper_space) .map(|view| view.name.clone()) .collect(); // Keep session-only choices while loading drawing fields from the layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); 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_format.label().to_string(); 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(); self.plot_prev = Some(previous); let cur = self.tabs[self.active_tab].scene.current_layout.clone(); let layout_entry = format!("*{cur}*"); 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); } 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); Task::none() } 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, STYLE_NONE, }; match msg { M::Close => { self.close_active_modal(); Task::none() } M::Printer(s) => { if s == OUT_PDF { self.plot_dialog.to_file = true; } else if s == OUT_DEFAULT { self.plot_dialog.to_file = false; self.plot_dialog.printer = None; } else { self.plot_dialog.to_file = false; self.plot_dialog.printer = Some(s); } Task::none() } M::PrinterProperties => { match crate::io::print_to_printer::open_printer_properties( self.plot_dialog.printer.as_deref(), ) { Ok(()) => self.command_line.push_info(crate::t!("Opened printer properties.").as_ref()), Err(error) => self.command_line.push_error(&error), } 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; } 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, _ => {} } 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; } 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.active_plot_style = Some(table); } Err(error) => { self.plot_dialog.style_name = name; self.plot_dialog.style_missing = true; self.command_line.push_error(&error); } } } Task::none() } M::PickWindow => { if self.print_all_options { return Task::none(); } let i = self.active_tab; if self.tabs[i].scene.current_layout != "Model" && self.tabs[i].scene.active_viewport.is_some() { // A layout Window is selected in paper coordinates and may // extend anywhere on the canvas, including outside the sheet. // Leave MSPACE so the floating viewport cannot constrain the // two picks to its model view. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = None; self.adopt_view_display(i); self.tabs[i].refresh_active_ucs(); self.refresh_properties(); self.sync_dyn_fields(); } self.close_active_modal(); Task::done(Message::Command("PLOTWINDOW".into())) } M::SelectSetup(name) => { self.select_page_setup(&name); if self.print_all_options { self.plot_dialog.paper_space = true; self.plot_dialog.area = "Layout".into(); } Task::none() } 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), } } 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. 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.paper = "A4".into(); d.orientation = "Landscape".into(); d.paper_width_mm = 297.0; d.paper_height_mm = 210.0; 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.upside_down = 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()); } 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). fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings { use acadrust::objects::{ PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; let d = &self.plot_dialog; let (w, h) = plot_dialog_sheet_mm(d); let mut ps = match self.plot_setup_template.clone() { Some(settings) => settings, None => { let mut settings = PlotSettings::new(""); settings.paper_units = PlotPaperUnits::Millimeters; settings } }; ps.paper_width = w; ps.paper_height = h; ps.paper_size = d.paper.clone(); 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 && d.area != "Layout"; ps.origin_x = if d.area == "Layout" { 0.0 } else { d.offset_x.parse::().unwrap_or(0.0) }; ps.origin_y = if d.area == "Layout" { 0.0 } else { d.offset_y.parse::().unwrap_or(0.0) }; let rot: i16 = if d.upside_down { 180 } else { 0 }; ps.rotation = match rot { 90 => PlotRotation::Degrees90, 180 => PlotRotation::Degrees180, 270 => PlotRotation::Degrees270, _ => PlotRotation::None, }; if d.area == "Layout" { ps.set_standard_scale(ScaledType::OneToOne); ps.standard_scale_factor = 1.0; } else if d.fit_to_paper { ps.set_scale_to_fit(); } else { let factor = plot_dialog_scale_factor(d); ps.scale_type = ScaledType::CustomScale; ps.scale_numerator = factor; ps.scale_denominator = 1.0; ps.standard_scale_factor = factor; ps.flags.use_standard_scale = false; } ps.printer_name = if d.to_file { crate::ui::window::plot::OUT_PDF.into() } else { d.printer.clone().unwrap_or_default() }; 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 && !d.style_name.is_empty(); ps.flags.show_plot_styles = d.show_plot_styles && !d.style_name.is_empty(); ps.flags.draw_viewports_first = d.paperspace_last; ps.flags.plot_hidden = d.shade == "Hidden Line"; 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 } /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { use acadrust::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, )); } 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)) { if let Ok(table) = crate::io::plot_style::PlotStyleTable::load_named( &ps.current_style_sheet, ) { self.active_plot_style = Some(table); } } let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone()); let style_name = if ps.current_style_sheet.is_empty() { 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)); let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); if !matches!( paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" ) && !ps.paper_size.is_empty() { paper = ps.paper_size.clone(); } let d = &mut self.plot_dialog; d.paper = paper; 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.offset_x = format!("{:.2}", ps.origin_x); d.offset_y = format!("{:.2}", ps.origin_y); 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 target_factor = if d.area == "Layout" { 1.0 } else if ps.flags.use_standard_scale { if ps.standard_scale_factor.is_finite() && ps.standard_scale_factor > 0.0 { ps.standard_scale_factor } else { ps.scale_type.scale_factor() } } else if ps.scale_denominator.abs() > 1e-9 { ps.scale_numerator / ps.scale_denominator } else { 1.0 }; if !d.fit_to_paper || !d.scales.iter().any(|(name, _)| name == &d.scale) { d.scale = scale_name_for_factor(&d.scales, target_factor) .or_else(|| scale_name_for_factor(&d.scales, 1.0)) .or_else(|| d.scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); } d.scale_lw = ps.flags.scale_lineweights && !d.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(); if ps.printer_name.to_ascii_lowercase().contains("pdf") { d.to_file = true; d.printer = None; } else { d.to_file = false; d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); } 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; } /// Copy transient paper/scale choices out of the dialog without changing /// the active layout. fn sync_dialog_plot_runtime(&mut self) { use crate::io::paper_sizes::{Orientation, PaperSize}; let d = self.plot_dialog.clone(); let paper = match d.paper.as_str() { "A3" => PaperSize::A3, "A2" => PaperSize::A2, "A1" => PaperSize::A1, "A0" => PaperSize::A0, "Letter" => PaperSize::Letter, "Legal" => PaperSize::Legal, "Tabloid" => PaperSize::Tabloid, _ => PaperSize::A4, }; let orient = if d.orientation == "Portrait" { Orientation::Portrait } else { Orientation::Landscape }; self.plot_format = paper; self.plot_orientation = orient; } fn apply_dialog_to_layout(&mut self) { self.sync_dialog_plot_runtime(); let d = self.plot_dialog.clone(); let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&d); let rotation: i16 = if d.upside_down { 180 } else { 0 }; let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); self.apply_plot_page_settings( sheet_w, sheet_h, &d.area, d.center, off_x, off_y, rotation, ); } /// 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(); 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(); // Preview and normal printing must not resize/mutate the live layout; // the runtime paper/scale choices still drive this one plot operation. self.sync_dialog_plot_runtime(); self.active_modal = None; self.reset_modal_geometry(); let plot_style = self.dialog_plot_style(&d); // 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(( w_wires, w_hatches, w_wipeouts, wgroup_splits, sw, sh, wox, woy, wrotation, wscale, wclip, )) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); self.active_modal = Some(crate::app::ModalKind::Plot); return Task::none(); }; // Preview renders to a temp PDF and opens it — never a save dialog, // whatever the output target is. if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); } if d.to_file { // Tested clipped export (opens a save dialog). return Task::done(Message::PlotWindowExport); } let tmp = crate::io::print_to_printer::temp_pdf_path("print"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let opts = self.plot_print_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::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 (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, &tmp, plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); } if d.to_file { // Reuse the tested PDF export flow (it opens a save dialog). return Task::done(Message::PlotExport); } let opts = self.plot_print_options(&d, group_splits); 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( wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, plot_style, 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, group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> 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()), render: Self::pdf_plot_options(d, group_splits), } } fn pdf_plot_options( d: &crate::ui::window::plot::PlotDialogState, group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::pdf_export::PdfPlotOptions { crate::io::pdf_export::PdfPlotOptions { object_lineweights: d.lineweights, scale_lineweights: d.scale_lw && !d.fit_to_paper, transparency: d.transparency, stamp: d.stamp, merge_lines: d.merge_lines, group_splits, } } 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; } self.active_plot_style .as_ref() .filter(|table| table.name.eq_ignore_ascii_case(&d.style_name)) .cloned() } fn window_plot_job(&self) -> Option { self.area_plot_job(self.plot_window?) } fn display_plot_job(&self) -> Option { self.area_plot_job(self.display_plot_window()?) } 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(())?; self.area_plot_job(( view.center.x - half_w, view.center.y - half_h, view.center.x + half_w, view.center.y + half_h, )) } 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 (wires, hatches, wipeouts, _) = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); let extents = plot_content_extents( &wires .iter() .filter(|wire| wire.name != "paper_printable_area") .cloned() .collect::>(), &hatches, &wipeouts, )?; 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().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_sizes::{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 target_x = if self.plot_dialog.center { centered_x } else { self.plot_dialog.offset_x.parse::().unwrap_or(0.0) }; let target_y = if self.plot_dialog.center { centered_y } else { self.plot_dialog.offset_y.parse::().unwrap_or(0.0) }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); let (all_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] .iter() .filter(|w| w.name != "paper_printable_area") .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) .count(); let wires: Vec<_> = all_wires .iter() .filter(|w| w.name != "paper_printable_area") .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) .cloned() .collect(); group_splits.wires = first_wire_count; 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(( wires, hatches, wipeouts, group_splits, page_w, page_h, offset_x, offset_y, rotation, scale as f32, clip, )) } 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, ) } }