//! `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::{ ortho_constrain, 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}; impl OpenCADStudio { /// Before a save, give every cached truck solid that still has no ACIS /// geometry (EXTRUDE/REVOLVE/SWEEP/LOFT/boolean results) an exact modeler /// body derived from its truck B-rep, so the written DWG/DXF carries real /// 3-D geometry other CAD apps can open instead of an empty data stream. /// Curved solids that the exact planar path can't yet express are left /// untouched (handled by the NURBS path). #[cfg(feature = "solid3d")] fn sync_truck_solids_to_acis(&mut self, i: usize) { use acadrust::EntityType; let scene = &mut self.tabs[i].scene; let targets: Vec = scene .solid_models .keys() .copied() .filter(|h| { matches!( scene.document.get_entity(*h), Some(EntityType::Solid3D(s)) if !s.acis_data.has_data() ) }) .collect(); for h in targets { // Build the SAT while borrowing solid_models; the returned document // is owned, so the borrow ends before we mutate the entity. let sat = scene .solid_models .get(&h) .and_then(crate::scene::convert::acis_export::planar_solid_to_sat); if let Some(sat) = sat { if let Some(EntityType::Solid3D(s)) = scene.document.get_entity_mut(h) { s.set_sat_document(&sat); } } } } #[cfg(not(feature = "solid3d"))] fn sync_truck_solids_to_acis(&mut self, _i: usize) {} /// 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, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, 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, osmode: crate::app::settings::osmode_from_snaps( self.snapper.enabled.iter(), self.snapper.snap_enabled, ), texteditmode: self.texteditmode, 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, pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), } } /// 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; // 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.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); self.plugin_repos = s.plugin_repos.clone(); self.command_line.literal_spaces = s.literal_spaces; 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; 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.pick_add = s.pick_add; self.pick_drag_rect = s.pick_drag_rect; self.default_bg_color = s.bg_color.map(u3_to_f4); self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4); // Push the restored background onto every drawing tab that exists now // (the start tab and any initial drawing). Tabs created later pick it // up via `apply_bg_default` at their construction site. for idx in 0..self.tabs.len() { self.apply_bg_default(idx); } self.rebuild_ribbon_modules(); } /// 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 the persisted default background(s) to tab `idx`. No-op for the /// start tab or when no default is set. Refreshes the tab's cached wires /// and meshes so background-adaptive colours pick up the change. pub(in crate::app) fn apply_bg_default(&mut self, idx: usize) { let bg = self.default_bg_color; let paper_bg = self.default_paper_bg_color; if bg.is_none() && paper_bg.is_none() { return; } let tab = &mut self.tabs[idx]; if tab.is_start { 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 { let i = self.active_tab; if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { let res = cmd.on_editor_closed(committed); self.tabs[i].active_cmd = Some(cmd); self.apply_cmd_result(res) } else { Task::none() } } /// 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 release tags. #[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 { crate::plugin::marketplace::fetch_releases(&repo).map(|rs| { rs.into_iter() .filter(|r| r.installable()) .map(|r| r.tag) .collect::>() }) }, move |res| Message::PluginReleasesFetched(label, res), ) } #[cfg(target_arch = "wasm32")] pub(in crate::app) fn fetch_releases_task(&self, _repo: String) -> Task { Task::none() } /// 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) }, Message::PluginInstalled, ) } #[cfg(target_arch = "wasm32")] pub(in crate::app) fn install_task(&self, _repo: String, _tag: String) -> Task { Task::none() } /// 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(), recent: crate::app::config::RecentConfig { files: self .recent_files .iter() .map(|p| p.to_string_lossy().into_owned()) .collect(), limit: self.recent_limit, }, statusbar: self.statusbar_config.clone(), ribbon: crate::app::config::RibbonConfig { collapse: self.ribbon.collapse_mode(), }, plot: self.plot_dialog.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.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.statusbar_config = cfg.statusbar; self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; } /// Write the config to disk only when it changed since the last write, so a /// toggle persists immediately without thrashing the file. 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) { self.save_config(); } /// 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. self.opening = Some(crate::app::OpenProgress { name: "Opening…".into(), size_bytes: 0, phase: std::sync::Arc::new(std::sync::atomic::AtomicU8::new( crate::app::OPEN_PHASE_READING, )), started: Instant::now(), }); Task::perform(crate::io::pick_and_load_web(), Message::FileOpened) } } /// 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 { 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) }) } /// 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(), } } pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, 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.take().map(|p| p.started); let timings = caches.timings; let entity_count = doc.entities().count(); self.command_line .push_output(&format!("Opened \"{name}\" — {entity_count} entities")); if caches.corrupt_dropped > 0 { self.command_line.push_error(&format!( "Warning: {} corrupt entities dropped (parser junk — bad normals / counts)", caches.corrupt_dropped )); } let thumbs_task = self.push_recent(path.clone()); 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_bg_default(idx); idx }; self.tabs[i].current_path = Some(path.clone()); self.tabs[i].scene.document = doc; // 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 state, like AutoCAD. 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); the multiplier we use // for text/dim sizing is its inverse (1:50 -> 0.02 -> 50.0). let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value; self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 { (1.0 / cannoscale_value) as f32 } else { 1.0 }; // Auto-resolve XREFs relative to the opened file's directory. let mut xref_ms = 0u32; let mut xref_merged = false; if let Some(base_dir) = path.parent() { // xref content arrives un-purged: parser-garbage entities // inside the referenced file can trigger infinite loops in // tessellation. `resolve_xrefs` runs the corrupt-entity // guard inline as it merges each xref, so no second // full-document walk is needed here. let t_xref = Instant::now(); let (xrefs, extra_dropped) = crate::io::xref::resolve_xrefs(&mut self.tabs[i].scene.document, base_dir); xref_ms = t_xref.elapsed().as_millis() as u32; if extra_dropped > 0 { self.command_line.push_error(&format!( "Warning: {extra_dropped} corrupt xref entities dropped" )); } for info in &xrefs { match info.status { crate::io::xref::XrefStatus::Loaded => { xref_merged = true; self.command_line .push_output(&format!("XREF Loaded \"{}\"", info.name)); } crate::io::xref::XrefStatus::NotFound => { self.command_line.push_error(&format!( "XREF Not found: \"{}\" ({})", info.name, info.path )); } crate::io::xref::XrefStatus::Unloaded => { self.command_line.push_info(&format!( "XREF Unloaded (skipped): \"{}\"", info.name )); } } } } // 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. let total_ms = open_started .map(|s| s.elapsed().as_millis() as u32) .unwrap_or(0); self.command_line.push_info(&format!( " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", timings.parse_ms, timings.purge_ms, timings.caches_ms, xref_ms, total_ms )); // 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; // Invalidate the wire cache so the new document is tessellated. self.tabs[i].scene.bump_geometry(); // XREFs are merged into the document AFTER the background worker // built the mesh caches above, so those caches contain none of // the xref'd geometry. The wire pass rebuilds from the document // each frame (bump_geometry covers it), but 3D-solid meshes are // only tessellated by populate — run the incremental variant so // the already-cached host solids are kept and only the newly // merged xref solids (walls, floors, roofs) are tessellated, // avoiding a full re-tessellation of the whole drawing. (#203) if xref_merged { self.tabs[i].scene.populate_missing_meshes_from_document(); } self.tabs[i].scene.selected = 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()), }; } // Rebuild the Isolate/Hide set from the entities the file itself // marks invisible (DXF code 60), so hidden objects stay hidden on // reopen and End Isolation can bring them back. self.tabs[i].scene.sync_hidden_from_invisible(); 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 = false; self.tabs[i].history = crate::app::document::HistoryState::default(); self.refresh_selected_grips(); Task::batch([thumbs_task, self.drain_pending_open()]) } pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task { let i = self.active_tab; let result = if block_name == "*" { let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); crate::modules::insert::wblock::extract_entities_to_doc( &self.tabs[i].scene.document, &handles, ) } else { crate::modules::insert::wblock::extract_block_to_doc( &self.tabs[i].scene.document, &block_name, ) }; match result { Ok(doc) => match crate::io::save(&doc, &path) { Ok(()) => { let fname = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); self.command_line.push_output(&format!( "WBLOCK Saved \"{block_name}\" → \"{fname}\"" )); } Err(e) => self .command_line .push_error(&format!("WBLOCK save failed: {e}")), }, Err(e) => self.command_line.push_error(&format!("WBLOCK: {e}")), } Task::none() } 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 mesh_refs: Vec<&crate::scene::model::mesh_model::MeshModel> = meshes.iter().collect(); match crate::io::stl::build_stl(&mesh_refs) { Some(bytes) => match std::fs::write(&path, bytes) { Ok(()) => self .command_line .push_output(&format!("STLOUT: exported to \"{}\"", path.display())), Err(e) => self .command_line .push_error(&format!("STLOUT: write error: {e}")), }, None => self .command_line .push_error("STLOUT: no mesh data to export."), } Task::none() } 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 mesh_refs: Vec<&crate::scene::model::mesh_model::MeshModel> = meshes.iter().collect(); match crate::io::step::build_step(&mesh_refs) { Some(text) => match std::fs::write(&path, text.as_bytes()) { Ok(()) => self .command_line .push_output(&format!("STEPOUT: exported to \"{}\"", path.display())), Err(e) => self .command_line .push_error(&format!("STEPOUT: write error: {e}")), }, None => self .command_line .push_error("STEPOUT: no mesh data to export."), } Task::none() } pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task { let src = match std::fs::read_to_string(&path) { Ok(s) => s, Err(e) => { self.command_line .push_error(&format!("IMPORTOBJ: read error: {e}")); return Task::none(); } }; let color = [0.7f32, 0.7, 0.85, 1.0]; match crate::io::obj::parse_obj(&src, color) { None => { self.command_line .push_error("IMPORTOBJ: no usable geometry in file."); } Some(mut mesh) => { let i = self.active_tab; let file_stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "obj_mesh".into()); mesh.name = file_stem.clone(); self.push_undo_snapshot(i, "IMPORTOBJ"); use crate::modules::insert::solid3d_cmds::empty_solid3d; let entity = empty_solid3d(); let handle = self.tabs[i].scene.add_entity(entity); if !handle.is_null() { self.tabs[i] .scene .meshes .insert(handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "IMPORTOBJ: imported \"{}\" as mesh.", file_stem )); } } } Task::none() } /// Rasterize the current drawing into the document's DWG preview image, so /// a saved file shows a thumbnail in file browsers and other CAD apps. /// A degenerate/empty drawing clears any stale preview. pub(super) fn stamp_thumbnail(&mut self, i: usize) { self.tabs[i].scene.document.preview = crate::io::thumbnail::from_scene(&self.tabs[i].scene); // The on-disk thumbnail will change — drop the cached Start-page handle // so it re-reads the updated file on the next refresh. if let Some(p) = self.tabs[i].current_path.clone() { self.recent_thumbs.remove(&p); } } pub(super) fn on_save_file(&mut self) -> Task { if self.read_only { self.command_line .push_error("Read-only session (--read-only): saving is disabled."); return Task::none(); } let i = self.active_tab; // Stamp the live grid/snap toggles onto the VPort so the file // reflects them even if they came from settings with no // in-session toggle (#121). self.sync_vport_display(i); // Persist Ortho ($ORTHOMODE) + running OSNAP ($OSMODE) into the // drawing header so they survive save/reopen (per-drawing). 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 let Some(path) = self.tabs[i].current_path.clone() { self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; // A direct Save preserves the document's current version. if self.backup_on_save { crate::io::write_backup(&path); } self.sync_truck_solids_to_acis(i); self.stamp_thumbnail(i); match crate::io::save(&self.tabs[i].scene.document, &path) { Ok(()) => { self.command_line .push_output(&format!("Saved: {}", path.display())); self.tabs[i].dirty = false; // A clean save supersedes any autosave recovery copy. let _ = std::fs::remove_file(path.with_extension("sv$")); } Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } return Task::none(); } self.save_dialog_for_unsaved = false; self.save_default_dwg2018(i) } /// Save without the version picker: default to DWG 2018 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_default_dwg2018(&mut self, tab_idx: usize) -> Task { self.active_tab = tab_idx; self.save_dialog_format = "DWG 2018".to_string(); 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!("{}.dwg", self.tabs[tab_idx].tab_display_name())); 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("Save Drawing As") .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(); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); self.sync_truck_solids_to_acis(i); self.stamp_thumbnail(i); match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); self.tabs[i].dirty = false; self.command_line.push_output(&format!("Saved: {filename}")); } Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } // Continue a pending tab close. if self.save_dialog_for_unsaved { if let Some(crate::app::PendingClose::Tab(idx)) = self.pending_close.take() { let cont = self.update(Message::TabClose(idx)); return Task::batch([close, cont]); } } 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; sync_annotation_scale_header(&mut self.tabs[i].scene); // Persist Ortho / running OSNAP into the header (Save-As path). self.stamp_header_sysvars(i); self.sync_truck_solids_to_acis(i); self.stamp_thumbnail(i); if self.backup_on_save { crate::io::write_backup(&path); } match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { Ok(()) => { self.command_line .push_output(&format!("Saved: {}", path.display())); // Drop any prior autosave copy — including the temp one used // while the drawing was still unsaved — before the tab takes on // its new path. let _ = std::fs::remove_file(self.autosave_target(i)); self.tabs[i].current_path = Some(path.clone()); self.tabs[i].dirty = false; let _ = std::fs::remove_file(path.with_extension("sv$")); if self.save_dialog_for_unsaved { return self.update(Message::UnsavedPickedSavePath(Some(path))); } } Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } Task::none() } /// 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 own DWG version (where the unsupported objects round-trip as /// verbatim bytes), then save. pub(super) fn on_aec_drop_same_version(&mut self) -> Task { let src = self.tabs[self.active_tab].scene.document.version; self.save_dialog_format = crate::io::format_for_version(src, false); // Strip any extension (e.g. .dxf) so the confirm path appends .dwg. 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: beside a saved /// drawing as `.sv$`, or — for an unsaved drawing with no path yet — /// under the system temp dir keyed by the tab's display name. #[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) => p.with_extension("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$")) } } } /// 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 n = 0; for i in 0..self.tabs.len() { if !self.tabs[i].dirty { continue; } let version = self.tabs[i].scene.document.version; if let Ok(bytes) = crate::io::save_to_bytes(&self.tabs[i].scene.document, "dwg", version) { if std::fs::write(self.autosave_target(i), bytes).is_ok() { n += 1; } } } if n > 0 { self.command_line .push_output(&format!("Autosaved {n} drawing(s)")); } Task::none() } #[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's Layout + /// PlotSettings objects (paper size, plot area, offset, rotation, scale). /// 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 on commit. #[allow(clippy::too_many_arguments)] pub(super) fn apply_plot_page_settings( &mut self, w: f64, h: f64, extents: bool, center: bool, offset_x: f64, offset_y: f64, rotation: i16, scale_str: &str, ) { let i = self.active_tab; let layout_name = self.tabs[i].scene.current_layout.clone(); if layout_name != "Model" { let w: f64 = w.max(1.0); let h: f64 = h.max(1.0); let plot_area = if extents { "Extents" } else { "Layout" }; // Update the Layout object's limits AND its embedded // PlotSettings fields. `paper_limits()` (sheet rendering) and // the DWG writer both read these from the Layout, so a page // setup that only touched a side PlotSettings object would not // reflect on screen or survive a save. The dialog's w/h are // the final sheet dimensions, so store them verbatim with no // further rotation swap (#156). for obj in self.tabs[i].scene.document.objects.values_mut() { if let acadrust::objects::ObjectType::Layout(l) = obj { if l.name == layout_name { l.min_limits = (0.0, 0.0); l.max_limits = (w, h); l.min_extents = (0.0, 0.0, 0.0); l.max_extents = (w, h, 0.0); l.paper_width = w; l.paper_height = h; l.plot_rotation = 0; l.plot_paper_units = 1; // millimetres l.plot_origin_x = offset_x; l.plot_origin_y = offset_y; // Custom dimensions no longer match a named size. l.paper_size = String::new(); break; } } } // Find or create the PlotSettings object for this layout. use acadrust::objects::{ ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, }; let plot_handle = self.tabs[i] .scene .document .objects .iter() .find_map(|(h, obj)| { if let ObjectType::PlotSettings(ps) = obj { if ps.page_name == layout_name { Some(*h) } else { None } } else { None } }); let ps_entry = if let Some(h) = plot_handle { self.tabs[i].scene.document.objects.get_mut(&h) } else { // Create a new PlotSettings object and insert it. let mut ps = PlotSettings::new(layout_name.clone()); ps.handle = self.tabs[i].scene.document.allocate_handle(); let h = ps.handle; self.tabs[i] .scene .document .objects .insert(h, ObjectType::PlotSettings(ps)); self.tabs[i].scene.document.objects.get_mut(&h) }; if let Some(ObjectType::PlotSettings(ps)) = ps_entry { ps.paper_width = w; ps.paper_height = h; ps.paper_units = PlotPaperUnits::Millimeters; ps.plot_type = if plot_area == "Extents" { PlotType::Extents } else { PlotType::Layout }; ps.flags.plot_centered = center; ps.origin_x = offset_x; ps.origin_y = offset_y; ps.rotation = match rotation { 90 => PlotRotation::Degrees90, 180 => PlotRotation::Degrees180, 270 => PlotRotation::Degrees270, _ => PlotRotation::None, }; // Apply plot scale. use acadrust::objects::ScaledType; let (num, den) = parse_plot_scale(scale_str); if scale_str == "Fit" { ps.set_scale_to_fit(); } else { ps.scale_type = ScaledType::CustomScale; ps.scale_numerator = num; ps.scale_denominator = den; } } self.tabs[i].dirty = true; // The paper sheet fill is cached; bump geometry so the new // sheet size re-tessellates and shows immediately. self.tabs[i].scene.bump_geometry(); self.command_line.push_info(&format!( "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ center={center} rot={rotation}°" )); } } pub(in crate::app) fn on_plot_export_path_some( &mut self, path: std::path::PathBuf, ) -> Task { let i = self.active_tab; let scene = &self.tabs[i].scene; let wires = scene.entity_wires(); let hatches = scene.paper_canvas_hatches(); let wipeouts = scene.paper_canvas_wipeouts(); // Read PlotSettings for current layout (if available). use acadrust::objects::PlotType; let ps_snap = scene.effective_plot_settings(); // Determine paper size and drawing offset. let (paper_w, paper_h, mut draw_ox, mut draw_oy, rotation_deg) = if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { // `paper_limits()` is in paper-space units; the PDF page is // physical millimetres, so scale the sheet size back to mm via // the layout's unit factor (identity for millimetre layouts). let uf = scene.paper_space_unit_factor(); let (pw, ph) = ((x1 - x0) / uf, (y1 - y0) / uf); // If PlotSettings says Extents, use model space extents instead. let use_extents = ps_snap .as_ref() .map(|ps| matches!(ps.plot_type, PlotType::Extents)) .unwrap_or(false); let (ox, oy) = if use_extents { if let Some((mn, _mx)) = scene.model_space_extents() { (-mn.x as f64, -mn.y as f64) } else { (-x0, -y0) } } else { (-x0, -y0) }; let rot = ps_snap .as_ref() .map(|ps| ps.rotation.to_degrees() as i32) .unwrap_or(0); (pw, ph, ox, oy, rot) } else { // Model space: fit with 5% margin. let margin = 1.05_f64; if let Some((mn, mx)) = scene.model_space_extents() { let w = ((mx.x - mn.x) as f64 * margin).max(1.0); let h = ((mx.y - mn.y) as f64 * margin).max(1.0); let pad_x = (w - (mx.x - mn.x) as f64) * 0.5; let pad_y = (h - (mx.y - mn.y) as f64) * 0.5; (w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0) } else { (297.0, 210.0, 0.0, 0.0, 0) } }; // Apply PlotSettings offset and centering. if let Some(ref ps) = ps_snap { if ps.flags.plot_centered { // Centering: compute wire extents and re-centre. let all_x: Vec = wires .iter() .flat_map(|w| w.points.iter().map(|p| p[0])) .filter(|v| !v.is_nan()) .collect(); let all_y: Vec = wires .iter() .flat_map(|w| w.points.iter().map(|p| p[1])) .filter(|v| !v.is_nan()) .collect(); if let (Some(&min_x), Some(&max_x), Some(&min_y), Some(&max_y)) = ( all_x.iter().copied().reduce(f32::min).as_ref(), all_x.iter().copied().reduce(f32::max).as_ref(), all_y.iter().copied().reduce(f32::min).as_ref(), all_y.iter().copied().reduce(f32::max).as_ref(), ) { let cx = (min_x + max_x) as f64 / 2.0; let cy = (min_y + max_y) as f64 / 2.0; draw_ox += paper_w / 2.0 - cx; draw_oy += paper_h / 2.0 - cy; } } else { draw_ox += ps.origin_x; draw_oy += ps.origin_y; } } // For rotation: swap paper dimensions and note angle for export. let (eff_w, eff_h) = match rotation_deg { 90 | 270 => (paper_h, paper_w), _ => (paper_w, paper_h), }; match crate::io::pdf_export::export_pdf( &wires, hatches.as_slice(), wipeouts.as_slice(), eff_w, eff_h, draw_ox, draw_oy, rotation_deg, 1.0, None, &path, self.active_plot_style.as_ref(), ) { // Full path, not just the file name — when the export was // driven by EXPORTPDF the user needs to see where // the file actually landed. (#369) Ok(()) => self .command_line .push_info(&format!("Exported: {}", path.display())), Err(e) => self.command_line.push_error(&format!("Export failed: {e}")), } Task::none() } /// Export the pending model-space plot window (set by PLOTWINDOW while on /// the Model tab) to PDF, using the chosen paper size/orientation/scale. pub(super) fn on_plot_window_export_path_some(&mut self, path: std::path::PathBuf) -> Task { use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; let i = self.active_tab; if self.tabs[i].scene.current_layout != "Model" { self.command_line .push_error("Plot window export is for model space."); return Task::none(); } let Some((x0, y0, x1, y1)) = self.plot_window else { self.command_line.push_error("No plot window. Pick one first."); return Task::none(); }; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { self.command_line .push_error("Plot window is empty. Pick a larger window."); return Task::none(); } let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); let win_w = (x1 - x0).max(1e-9); let win_h = (y1 - y0).max(1e-9); let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") { PlotScale::Fit } else { let (num, den) = parse_plot_scale(&self.plot_scale); if num > 0.0 && den > 0.0 { PlotScale::Ratio(num / den) } else { PlotScale::Fit } }; let (scale, ox, oy) = window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel); let scene = &self.tabs[i].scene; // Cull to the window so a small-area plot doesn't write the whole // drawing into the PDF; the clip below trims the partial crossers. // aabb is world X/Y [minx, miny, maxx, maxy]. let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); let wires: Vec<_> = scene .entity_wires() .into_iter() .filter(|w| { w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 }) .collect(); let hatches = scene.paper_canvas_hatches(); let wipeouts = scene.paper_canvas_wipeouts(); // build_pdf maps a wire coordinate to sheet mm as (coord + offset) * // scale (rotation_deg == 0 adds no further CTM translation). // window_to_sheet's (ox, oy) is the sheet-mm target for the window's // min corner, so: scale * (x0 + offset_x) = ox => offset_x = ox/scale - x0. let offset_x = (ox / scale) - x0; let offset_y = (oy / scale) - y0; // Clip rect in pre-scale space (build_pdf's CTM applies `scale` to it, // same as the wires) so the final sheet-mm rect lands at (ox, oy). let clip = Some(((ox / scale) as f32, (oy / scale) as f32, win_w as f32, win_h as f32)); let res = crate::io::pdf_export::export_pdf( &wires, hatches.as_slice(), wipeouts.as_slice(), sheet_w, sheet_h, offset_x, offset_y, 0, scale as f32, clip, &path, self.active_plot_style.as_ref(), ); match res { Ok(()) => { self.command_line.push_info(&format!( "Plotted window to {}", path.file_name().unwrap_or_default().to_string_lossy() )); self.close_active_modal(); } Err(e) => self.command_line.push_error(&format!("Plot failed: {e}")), } 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, /// the draw-origin offset, and the rotation. Shared by print + preview so /// the Plot dialog reuses the same tested derivation. #[allow(clippy::type_complexity)] pub(super) fn layout_plot_params( &self, ) -> ( Vec, Vec, Vec, f64, f64, // Draw offsets stay f64: they cancel an absolute world coordinate that // an f32 cannot hold at UTM scale. f64, f64, i32, ) { let i = self.active_tab; let scene = &self.tabs[i].scene; let wires = scene.entity_wires(); let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); use acadrust::objects::PlotType; let ps_snap = scene.effective_plot_settings(); let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) = if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { // `paper_limits()` is in paper-space units; the PDF page is // physical millimetres, so scale the sheet size back to mm via // the layout's unit factor (identity for millimetre layouts). let uf = scene.paper_space_unit_factor(); let (pw, ph) = ((x1 - x0) / uf, (y1 - y0) / uf); let use_extents = ps_snap .as_ref() .map(|ps| matches!(ps.plot_type, PlotType::Extents)) .unwrap_or(false); let (ox, oy) = if use_extents { if let Some((mn, _mx)) = scene.model_space_extents() { (-mn.x as f64, -mn.y as f64) } else { (-x0, -y0) } } else { (-x0, -y0) }; let rot = ps_snap .as_ref() .map(|ps| ps.rotation.to_degrees() as i32) .unwrap_or(0); (pw, ph, ox, oy, rot) } else if let Some((mn, mx)) = scene.model_space_extents() { let margin = 1.05_f64; let w = ((mx.x - mn.x) as f64 * margin).max(1.0); let h = ((mx.y - mn.y) as f64 * margin).max(1.0); let pad_x = (w - (mx.x - mn.x) as f64) * 0.5; let pad_y = (h - (mx.y - mn.y) as f64) * 0.5; (w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0) } else { (297.0, 210.0, 0.0, 0.0, 0) }; let (eff_w, eff_h) = match rotation_deg { 90 | 270 => (paper_h, paper_w), _ => (paper_w, paper_h), }; ( wires, hatches, wipeouts, eff_w, eff_h, draw_ox, draw_oy, rotation_deg, ) } pub(super) fn on_print_to_printer(&mut self) -> Task { let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg) = self.layout_plot_params(); let plot_style = self.active_plot_style.clone(); self.command_line.push_info("Sending to system printer…"); Task::perform( async move { crate::io::print_to_printer::print_wires( wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg, plot_style, ) .await }, 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("Quick print works in model space."); 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() { 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("Selection has no printable geometry."); return Task::none(); } if !(x1 > x0 && y1 > y0) { self.command_line .push_error("Selection has no printable area."); 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; // Keep the user's last print preferences (printer, copies, quality, // output options — persisted in `self.plot_dialog`); only refresh the // runtime printer list and reseed the drawing-specific fields from the // active layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); d.paper = self.plot_format.label().to_string(); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", Orientation::Landscape => "Landscape", } .to_string(); d.style_name = self .active_plot_style .as_ref() .map(|t| t.name.clone()) .unwrap_or_default(); // `area` and `scale` are remembered user choices (default Window / Fit), // not reseeded from the layout. Offset / center / rotation ARE layout // properties, so reflect them. if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() { let d = &mut self.plot_dialog; 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; d.rotation = format!("{deg}°"); } self.plot_dialog.name_input = None; self.plot_dialog.name_rename = false; // Refresh the list ( / / layouts / named setups) and // snapshot the just-loaded settings as the `` restore point. self.refresh_page_setups(); self.plot_prev = Some(self.plot_dialog.clone()); // Default selection: model space → ; a layout → its own setup. let cur = self.tabs[self.active_tab].scene.current_layout.clone(); if cur == "Model" { self.select_page_setup(crate::ui::window::plot::SETUP_NONE); } else { self.select_page_setup(&format!("*{cur}*")); } self.active_modal = Some(crate::app::ModalKind::Plot); Task::none() } /// 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}; 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::Paper(s) => { self.plot_dialog.paper = s; Task::none() } M::Orientation(s) => { self.plot_dialog.orientation = s; Task::none() } M::Rotation(s) => { self.plot_dialog.rotation = s; Task::none() } M::Area(s) => { self.plot_dialog.area = s; Task::none() } M::Scale(s) => { 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::Dpi(s) => { self.plot_dialog.dpi = s; Task::none() } M::Flag(f) => { let d = &mut self.plot_dialog; match f { PlotFlag::Center => d.center = !d.center, PlotFlag::ScaleLw => d.scale_lw = !d.scale_lw, PlotFlag::Mono => d.mono = !d.mono, PlotFlag::Lineweights => d.lineweights = !d.lineweights, PlotFlag::WithStyles => d.with_styles = !d.with_styles, PlotFlag::Transparency => d.transparency = !d.transparency, PlotFlag::PaperspaceLast => d.paperspace_last = !d.paperspace_last, PlotFlag::HidePaperspace => d.hide_paperspace = !d.hide_paperspace, PlotFlag::Stamp => d.stamp = !d.stamp, PlotFlag::SaveLayout => d.save_layout = !d.save_layout, } Task::none() } M::LoadStyle => Task::done(Message::PlotStyleLoad), M::ClearStyle => { self.active_plot_style = None; self.plot_dialog.style_name.clear(); Task::none() } M::PickWindow => { self.close_active_modal(); Task::done(Message::Command("PLOTWINDOW".into())) } M::SelectSetup(name) => { self.select_page_setup(&name); Task::none() } M::SetCurrent => { self.apply_dialog_to_layout(); self.command_line.push_info("Page setup applied to the layout."); Task::none() } M::NewSetup => { // 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 => { // 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) => { // 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 => { 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 => 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 { // 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.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.rotation = "0°".into(); d.scale = "Fit".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, }; use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let d = &self.plot_dialog; let paper = match d.paper.as_str() { "A3" => PaperSize::A3, "A2" => PaperSize::A2, "A1" => PaperSize::A1, "A0" => PaperSize::A0, _ => PaperSize::A4, }; let orient = if d.orientation == "Portrait" { Orientation::Portrait } else { Orientation::Landscape }; let (w, h) = sheet_mm(paper, orient); let mut ps = PlotSettings::new(""); ps.paper_width = w; ps.paper_height = h; ps.paper_units = PlotPaperUnits::Millimeters; ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, "Layout" => PlotType::Layout, _ => PlotType::Extents, }; ps.flags.plot_centered = d.center; ps.origin_x = d.offset_x.parse::().unwrap_or(0.0); ps.origin_y = d.offset_y.parse::().unwrap_or(0.0); let rot: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); ps.rotation = match rot { 90 => PlotRotation::Degrees90, 180 => PlotRotation::Degrees180, 270 => PlotRotation::Degrees270, _ => PlotRotation::None, }; if d.scale == "Fit" { ps.set_scale_to_fit(); } else { let (num, den) = parse_plot_scale(&d.scale); ps.scale_type = ScaledType::CustomScale; ps.scale_numerator = num; ps.scale_denominator = den; } ps.printer_name = d.printer.clone().unwrap_or_default(); ps.current_style_sheet = d.style_name.clone(); ps } /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { use acadrust::objects::PlotType; let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); let d = &mut self.plot_dialog; d.paper = paper; d.orientation = orient; d.area = match ps.plot_type { PlotType::Window => "Window", PlotType::Layout => "Layout", _ => "Extents", } .to_string(); 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; d.rotation = format!("{deg}°"); d.scale = if ps.is_scale_to_fit() { "Fit".into() } else { let n = ps.scale_numerator; let m = ps.scale_denominator; if (n - 1.0).abs() < 1e-9 { format!("1:{}", m as i64) } else if (m - 1.0).abs() < 1e-9 { format!("{}:1", n as i64) } else { "Fit".into() } }; } /// Write the current dialog fields into the active layout's plot settings. /// Shared by the "Set current" action and the print/export commit. fn apply_dialog_to_layout(&mut self) { use crate::io::paper_sizes::{sheet_mm, 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, _ => PaperSize::A4, }; let orient = if d.orientation == "Portrait" { Orientation::Portrait } else { Orientation::Landscape }; let (sheet_w, sheet_h) = sheet_mm(paper, orient); self.plot_format = paper; self.plot_orientation = orient; self.plot_scale = d.scale.clone(); let rotation: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); let extents = d.area != "Layout"; self.apply_plot_page_settings( sheet_w, sheet_h, extents, d.center, off_x, off_y, rotation, &d.scale, ); } /// Apply the dialog's page settings to the layout, then either 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 { // Remember the user's print preferences across sessions. self.save_config(); let d = self.plot_dialog.clone(); // Persist the dialog's page settings into the layout, then reuse the // tested layout-plot derivation. self.apply_dialog_to_layout(); self.active_modal = None; let plot_style = if d.with_styles { self.active_plot_style.clone() } else { None }; // ── Window area: clipped model-space plot ──────────────────────────── if d.area == "Window" { // Preview renders to a temp PDF and opens it — never a save dialog, // whatever the output target is. if preview { let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) = self.window_plot_job() else { self.command_line .push_error("Pick a plot window first (model space)."); self.active_modal = Some(crate::app::ModalKind::Plot); return Task::none(); }; let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); let exp = crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp, plot_style.as_ref(), ); match exp.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) { Ok(()) => self.command_line.push_info("Opened plot preview."), Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")), } self.active_modal = Some(crate::app::ModalKind::Plot); return Task::none(); } if d.to_file { // Tested clipped export (opens a save dialog). return Task::done(Message::PlotWindowExport); } let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) = self.window_plot_job() else { self.command_line .push_error("Pick a plot window first (model space)."); self.active_modal = Some(crate::app::ModalKind::Plot); return Task::none(); }; let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); let exp = crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp, plot_style.as_ref(), ); let opts = self.plot_print_options(&d); match exp.and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) { Ok(printer) => self .command_line .push_info(&format!("Sent to printer: {printer}")), Err(e) => self.command_line.push_error(&format!("Print failed: {e}")), } return Task::none(); } let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot) = self.layout_plot_params(); if preview { let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); let res = crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, eff_w, eff_h, ox, oy, rot, 1.0, None, &tmp, plot_style.as_ref(), ); match res.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) { Ok(()) => self.command_line.push_info("Opened plot preview."), Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")), } // Preview leaves the dialog open for further tweaks. self.active_modal = Some(crate::app::ModalKind::Plot); return Task::none(); } if d.to_file { // Reuse the tested PDF export flow (it opens a save dialog). return Task::done(Message::PlotExport); } let opts = self.plot_print_options(&d); self.command_line.push_info("Sending to system printer…"); Task::perform( async move { crate::io::print_to_printer::print_wires_with( wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot, plot_style, opts, ) .await }, Message::PrintResult, ) } /// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from /// the dialog state. fn plot_print_options( &self, d: &crate::ui::window::plot::PlotDialogState, ) -> crate::io::print_to_printer::PrintOptions { crate::io::print_to_printer::PrintOptions { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), mono: d.mono, quality: Some(d.quality.clone()), dpi: d.dpi.trim().parse::().ok().filter(|v| *v > 0), } } /// Render inputs + page geometry for a clipped model-space window plot /// (the pending `plot_window`, fitted onto the dialog's sheet). Returns /// `None` when not in model space or no window has been picked. Mirrors /// [`Self::on_plot_window_export_path_some`] but hands back the params so /// the plot can go to a printer or preview, not only a save-dialog PDF. #[allow(clippy::type_complexity)] fn window_plot_job( &self, ) -> Option<( Vec, Vec, Vec, f64, f64, // Offsets stay f64 (they cancel a UTM-scale world coordinate); the plot // scale is a small ratio and stays f32. f64, f64, f32, Option<(f32, f32, f32, f32)>, )> { use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; let i = self.active_tab; if self.tabs[i].scene.current_layout != "Model" { return None; } let (x0, y0, x1, y1) = self.plot_window?; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { return None; } let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); let win_w = (x1 - x0).max(1e-9); let win_h = (y1 - y0).max(1e-9); let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") { PlotScale::Fit } else { let (num, den) = parse_plot_scale(&self.plot_scale); if num > 0.0 && den > 0.0 { PlotScale::Ratio(num / den) } else { PlotScale::Fit } }; let (scale, ox, oy) = window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel); let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); let wires: Vec<_> = scene .entity_wires() .into_iter() .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) .collect(); let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); let offset_x = (ox / scale) - x0; let offset_y = (oy / scale) - y0; let clip = Some(( (ox / scale) as f32, (oy / scale) as f32, win_w as f32, win_h as f32, )); Some(( wires, hatches, wipeouts, sheet_w, sheet_h, offset_x, offset_y, 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); } self.command_line .push_output(&format!("Plot style ACI {aci} updated.")); } } 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(&format!("Created new CTB table, ACI {aci} updated.")); } 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("No plot style table loaded. Load or create one first."); 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 { crate::sys::file_dialog() .set_title("Save Plot Style Table") .set_file_name(&default_name) .add_filter("Plot Style Files", &["ctb", "stb", "CTB", "STB"]) .add_filter("All Files", &["*"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)) }, Message::PlotStylePanelSavePath, ) } }