//! `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 { /// 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, ortho: self.ortho_mode, polar: self.polar_mode, polar_increment_deg: self.polar_increment_deg, snap_enabled: self.snapper.snap_enabled, otrack: self.snapper.otrack_enabled, snap_modes: crate::app::settings::UserSettings::modes_from(self.snapper.enabled.iter()), 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(), texteditmode: self.texteditmode, backup_on_save: self.backup_on_save, 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.ortho_mode = s.ortho; self.polar_mode = s.polar; self.polar_increment_deg = s.polar_increment_deg; self.snapper.snap_enabled = s.snap_enabled; self.snapper.otrack_enabled = s.otrack; self.snapper.enabled = s.snap_modes.iter().copied().collect(); 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.texteditmode = s.texteditmode; self.backup_on_save = s.backup_on_save; 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(); } /// 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); } /// 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() } /// Write preferences to disk only when they differ from the last write, /// so a toggle persists immediately without thrashing the file. pub(in crate::app) fn persist_settings_if_changed(&mut self) { let cur = self.current_settings(); if self.last_saved_settings.as_ref() != Some(&cur) { cur.save(); self.last_saved_settings = Some(cur); } } /// 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) } } 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 )); } self.app_menu.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; // Follow the file's saved current UCS from the moment it opens. self.tabs[i].adopt_active_ucs_from_header(); // 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; 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 => { 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(); self.tabs[i].scene.selected = rustc_hash::FxHashSet::default(); self.tabs[i].scene.preview_wires = vec![]; self.tabs[i].scene.current_layout = "Model".to_string(); 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::none() } 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() } 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); // 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); } 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; } Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } return Task::none(); } self.save_dialog_for_unsaved = false; self.open_save_dialog_window(i) } 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(); // Confirm before overwriting an existing file in the folder. #[cfg(not(target_arch = "wasm32"))] if !self.overwrite_acknowledged && self.save_dialog_folder.join(&filename).exists() { self.active_modal = Some(crate::app::ModalKind::OverwriteWarning); return Task::none(); } let close = self.close_save_dialog_window(); let i = self.active_tab; sync_annotation_scale_header(&mut self.tabs[i].scene); // Native: write to the chosen path. Web: download the bytes // under the chosen name (no filesystem). #[cfg(not(target_arch = "wasm32"))] { let path = self.save_dialog_folder.join(&filename); 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())); self.tabs[i].current_path = Some(path.clone()); self.tabs[i].dirty = false; if self.save_dialog_for_unsaved { let next = self.update(Message::UnsavedPickedSavePath(Some(path))); return Task::batch([close, next]); } } Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } close } #[cfg(target_arch = "wasm32")] { 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 } } /// 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() } /// Overwrite warning → "Replace": save over the existing file. pub(super) fn on_overwrite_confirm(&mut self) -> Task { self.overwrite_acknowledged = true; self.active_modal = Some(crate::app::ModalKind::SaveDialog); self.on_save_dialog_confirm() } pub(super) fn on_page_setup_commit(&mut self) -> Task { let i = self.active_tab; let layout_name = self.tabs[i].scene.current_layout.clone(); if layout_name != "Model" { let w: f64 = self.page_setup_w.parse::().unwrap_or(297.0).max(1.0); let h: f64 = self.page_setup_h.parse::().unwrap_or(210.0).max(1.0); let plot_area = self.page_setup_plot_area.clone(); let center = self.page_setup_center; let offset_x = self.page_setup_offset_x.parse::().unwrap_or(0.0); let offset_y = self.page_setup_offset_y.parse::().unwrap_or(0.0); let rotation: i16 = self.page_setup_rotation.parse().unwrap_or(0); let scale_str = self.page_setup_scale.clone(); // 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}°" )); } self.active_modal = None; Task::none() } pub(super) 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() { let (pw, ph) = (x1 - x0, y1 - y0); // 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 as f32, draw_oy as f32, rotation_deg, &path, self.active_plot_style.as_ref(), ) { Ok(()) => self.command_line.push_info(&format!( "Exported: {}", path.file_name().unwrap_or_default().to_string_lossy() )), Err(e) => self.command_line.push_error(&format!("Export failed: {e}")), } Task::none() } pub(super) fn on_print_to_printer(&mut self) -> Task { 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() { let (pw, ph) = (x1 - x0, y1 - y0); 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), }; 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, draw_ox as f32, draw_oy as f32, rotation_deg, plot_style, ) .await }, Message::PrintResult, ) } 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 { rfd::AsyncFileDialog::new() .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, ) } }