cad-editor/src/app/update/file.rs
2026-07-25 21:34:29 +03:00

2431 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
#![allow(unused_imports)]
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
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};
pub(super) fn background_task<T, F, M>(work: F, map: M) -> Task<Message>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
M: FnOnce(T) -> Message + Send + 'static,
{
#[cfg(not(target_arch = "wasm32"))]
{
let (tx, rx) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let _ = tx.send(work());
});
Task::perform(
async move { rx.await.expect("background export worker dropped") },
map,
)
}
#[cfg(target_arch = "wasm32")]
{
Task::perform(async move { work() }, map)
}
}
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<acadrust::Handle> = 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<String> = 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<Message> {
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<String> {
self.disabled_plugins.clone()
}
/// Background task: fetch the curated plugin registry.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_registry_task(&self) -> Task<Message> {
Task::perform(
async { crate::plugin::marketplace::fetch_registry() },
Message::PluginRegistryFetched,
)
}
/// Background task: fetch `owner/repo`'s installable release tags.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
let label = repo.clone();
Task::perform(
async move {
crate::plugin::marketplace::fetch_releases(&repo).map(|rs| {
rs.into_iter()
.filter(|r| r.installable())
.map(|r| r.tag)
.collect::<Vec<_>>()
})
},
move |res| Message::PluginReleasesFetched(label, res),
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn fetch_releases_task(&self, _repo: String) -> Task<Message> {
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<Message> {
Task::perform(
async move {
let releases = crate::plugin::marketplace::fetch_releases(&repo)?;
let rel = releases
.into_iter()
.find(|r| r.tag == tag)
.ok_or_else(|| format!("release {tag} not found"))?;
crate::plugin::marketplace::install(&rel)
},
Message::PluginInstalled,
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn install_task(&self, _repo: String, _tag: String) -> Task<Message> {
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,
},
start: crate::app::config::StartConfig {
section: self.start_section,
},
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.start_section = cfg.start.section;
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<Message> {
// Native: pick a path, then load on a worker thread. Web: the
// browser hands back bytes, so pick + parse in one step and feed
// the shared `FileOpened` handler directly.
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
}
#[cfg(target_arch = "wasm32")]
{
// `FileOpened` only installs the result when an open is in
// progress, so mark one. The browser picker + parse happen
// inside `pick_and_load_web`; the real name is unknown until
// then, so show a generic label meanwhile.
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING,
));
self.opening = Some(crate::app::OpenProgress {
name: "Opening…".into(),
size_bytes: 0,
state: state.clone(),
started: Instant::now(),
});
Task::perform(crate::io::pick_and_load_web(state), 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<usize> {
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<Message> {
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,
mut caches: crate::scene::DerivedCaches,
) -> Task<Message> {
// If the user clicked Cancel while the parser was running, the
// overlay state was cleared and we silently drop the result.
if self.opening.is_none() {
return Task::none();
}
let open_started = self.opening.as_ref().map(|p| p.started);
let 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
));
}
if caches.xref_dropped > 0 {
self.command_line.push_error(&format!(
"Warning: {} corrupt xref entities dropped",
caches.xref_dropped
));
}
for info in &caches.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));
}
}
}
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
};
// 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, timings.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;
let prepared_geometry = caches.prepared_geometry.take();
// Invalidate the wire cache so the new document is tessellated.
self.tabs[i].scene.bump_geometry();
if let Some(prepared) = prepared_geometry {
self.tabs[i].scene.install_prepared_open_geometry(prepared);
}
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();
#[cfg(not(target_arch = "wasm32"))]
let interaction_task = {
let wires = self.tabs[i].scene.hit_test_wires();
let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1;
self.prepare_interaction_index_task(i, wires, screen_height)
.unwrap_or_else(Task::none)
};
#[cfg(target_arch = "wasm32")]
let interaction_task = Task::none();
if let Some(opening) = &self.opening {
opening
.state
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
let pending_open_task = self.drain_pending_open();
Task::batch([thumbs_task, pending_open_task, interaction_task])
}
pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf,
) -> Task<Message> {
let i = self.active_tab;
let document = self.tabs[i].scene.document.clone();
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
let worker_name = block_name.clone();
let worker_path = path.clone();
background_task(
move || {
let document = if worker_name == "*" {
crate::modules::insert::wblock::extract_entities_to_doc(
&document,
&handles,
)
} else {
crate::modules::insert::wblock::extract_block_to_doc(
&document,
&worker_name)
}
.map_err(|e| e.to_string())?; crate::io::save(&document, &worker_path).map_err(|e| e.to_string())
},
move |result| Message::WblockWriteFinished(block_name, path, result),)
}
pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
// Re-build STL bytes (we can't easily pass them through the message).
let i = self.active_tab;
// STL gets the highest-resolution LOD (slot 0) so the
// exported geometry isn't downgraded by the view-dependent
// mesh LOD ladder used for rendering.
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let worker_path = path.clone();
background_task(
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let bytes = crate::io::stl::build_stl(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
std::fs::write(&worker_path, bytes).map_err(|e| e.to_string())
},
move |result| Message::StlExportFinished(path, result),)
}
pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let i = self.active_tab;
// Export uses LOD 0 (full resolution); see StlExportPath above.
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let worker_path = path.clone();
background_task(
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let text = crate::io::step::build_step(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string())
},
move |result| Message::StepExportFinished(path, result),)
}
pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let tab_id = self.tabs[self.active_tab].id;
let worker_path = path.clone();
background_task(
move || {
let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0])
.ok_or_else(|| "no usable geometry in file".to_string())
},
move |result| Message::ObjImportFinished(tab_id, path, result),)
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn prepare_native_save(&mut self, i: usize) {
self.sync_vport_display(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64;
self.sync_truck_solids_to_acis(i);
}
#[cfg(target_arch = "wasm32")]
fn stamp_thumbnail(&mut self, i: usize, version: acadrust::DxfVersion) {
let scene = &self.tabs[i].scene;
let preview = crate::io::thumbnail::from_snapshot(
&scene.entity_wires(),
&scene.camera.borrow(),
scene.bg_color,
version >= acadrust::DxfVersion::AC1027,
self.vp_size,
);
self.tabs[i].scene.document.preview = preview;
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn queue_native_save(
&mut self,
i: usize,
path: std::path::PathBuf,
version: acadrust::DxfVersion,
purpose: crate::app::SavePurpose,
continuation: crate::app::SaveContinuation,
set_current_path: bool,
) -> Task<Message> {
let tab_id = self.tabs[i].id;
if self.active_save_jobs.contains_key(&tab_id) {
if purpose != crate::app::SavePurpose::Autosave {
self.command_line
.push_info("Save already running for this drawing.");
}
return Task::none();
}
let epoch = self.tabs[i].scene.geometry_epoch;
let revision = self.tabs[i].edit_revision;
let camera_generation = self.tabs[i].scene.camera_generation;
let thumbnail_key = (purpose != crate::app::SavePurpose::Autosave).then(|| {
let scene = &self.tabs[i].scene;
crate::app::ThumbnailCacheKey {
epoch,
camera_generation,
bg_color: scene.bg_color.map(f32::to_bits),
png: version >= acadrust::DxfVersion::AC1027,
viewport: [self.vp_size.0.to_bits(), self.vp_size.1.to_bits()],
}
});
let thumbnail = if thumbnail_key == self.tabs[i].thumbnail_cache_key {
None
} else {
thumbnail_key.map(|key| {
let scene = &self.tabs[i].scene;
(
scene.entity_wires(),
scene.camera.borrow().clone(),
scene.bg_color,
key.png,
self.vp_size,
)
})
};
let clone_started = iced::time::Instant::now();
let mut snapshot = self.tabs[i].scene.document.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-snapshot {:.1}ms entities={} objects={} purpose={purpose:?}",
clone_ms,
snapshot.entities().count(),
snapshot.objects.len(),
);
}
self.save_job_serial = self.save_job_serial.wrapping_add(1);
let job_id = self.save_job_serial;
self.active_save_jobs.insert(tab_id, job_id);
let previous_autosave = set_current_path.then(|| self.autosave_target(i));
let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save;
let worker_path = path.clone();
Task::perform(
async move {
let (result, refreshed_preview) = std::thread::spawn(move || {
let mut refreshed_preview = None;
if let Some((wires, camera, bg_color, png, viewport)) = thumbnail {
let started = iced::time::Instant::now();
snapshot.preview = crate::io::thumbnail::from_snapshot(
&wires,
&camera,
bg_color,
png,
viewport,
);
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-thumbnail {:.1}ms wires={}",
started.elapsed().as_secs_f64() * 1000.0,
wires.len(),
);
}
refreshed_preview = Some(snapshot.preview.clone());
}
let result = crate::io::save_owned_as_version_atomic(
snapshot,
&worker_path,
version,
backup,
);
(result, refreshed_preview)
})
.join()
.unwrap_or_else(|_| (Err("save worker panicked".to_string()), None));
crate::app::SaveOutcome {
job_id,
tab_id,
epoch,
revision,
camera_generation,
path,
previous_autosave,
set_current_path,
purpose,
continuation,
thumbnail_key,
refreshed_preview,
result,
}
},
Message::SaveFinished,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_finished(
&mut self,
outcome: crate::app::SaveOutcome,
) -> Task<Message> {
let latest = self.active_save_jobs.get(&outcome.tab_id).copied()
== Some(outcome.job_id);
if latest {
self.active_save_jobs.remove(&outcome.tab_id);
}
let Some(i) = self.tabs.iter().position(|tab| tab.id == outcome.tab_id) else {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
};
if !latest {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
}
if let Err(error) = outcome.result {
self.command_line
.push_error(&format!("Save failed: {error}"));
return match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::None => Task::none(),
};
}
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == outcome.epoch
&& self.tabs[i].edit_revision == outcome.revision
&& self.tabs[i].scene.camera_generation == outcome.camera_generation;
if snapshot_is_current && outcome.purpose != crate::app::SavePurpose::Autosave {
if let Some(preview) = outcome.refreshed_preview {
self.tabs[i].scene.document.preview = preview;
}
self.tabs[i].thumbnail_cache_key = outcome.thumbnail_key;
}
let mut tasks = Vec::new();
match outcome.purpose {
crate::app::SavePurpose::Autosave => {
self.command_line.push_output("Autosaved 1 drawing");
}
crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => {
self.command_line
.push_output(&format!("Saved: {}", outcome.path.display()));
self.recent_thumbs.remove(&outcome.path);
if let Some(previous) = outcome.previous_autosave {
if previous != outcome.path {
let _ = std::fs::remove_file(previous);
}
}
if outcome.set_current_path {
self.tabs[i].current_path = Some(outcome.path.clone());
tasks.push(self.push_recent(outcome.path.clone()));
}
if snapshot_is_current {
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(outcome.path.with_extension("sv$"));
}
}
}
match outcome.continuation {
crate::app::SaveContinuation::None => {}
crate::app::SaveContinuation::CloseTab if snapshot_is_current => {
self.pending_close = None;
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.update(Message::TabClose(i)));
}
crate::app::SaveContinuation::Quit if snapshot_is_current => {
self.pending_close = None;
if self.tabs.iter().any(|tab| tab.dirty) {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
} else {
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.exit_app());
}
}
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
tasks.push(self.open_unsaved_dialog_window());
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
}
}
Task::batch(tasks)
}
pub(super) fn on_save_file(&mut self) -> Task<Message> {
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;
// Web serializes immediately below. Native preparation happens
// once after the destination/version is known.
#[cfg(target_arch = "wasm32")]
{
self.sync_vport_display(i);
self.stamp_header_sysvars(i);
}
// Native: save straight to the known path. Web has no path
// (downloads instead), so always go through the Save dialog.
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = self.tabs[i].current_path.clone() {
// A direct Save preserves the document's current version.
let ver = self.tabs[i].scene.document.version;
self.prepare_native_save(i);
return self.queue_native_save(
i,
path,
ver,
crate::app::SavePurpose::Manual,
crate::app::SaveContinuation::None,
false,
);
}
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<Message> {
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<Message> {
let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
// Warn before a lossy Save-As that would drop unsupported
// (AEC / application) objects kept only as verbatim
// source-version bytes — let the user keep them by saving in the
// source version, or proceed and drop them.
if !self.aec_drop_acknowledged {
let is_dxf = ext.eq_ignore_ascii_case("dxf");
let n = crate::io::dropped_on_save_count(
&self.tabs[self.active_tab].scene.document,
version,
is_dxf,
);
if n > 0 {
self.aec_drop_count = n;
self.active_modal = Some(crate::app::ModalKind::AecDropWarning);
return Task::none();
}
}
// The user need not type an extension: append the selected
// format's one when the entered name carries none.
let name = self.save_dialog_filename.trim();
let filename = if name.is_empty() {
format!("drawing.{ext}")
} else if std::path::Path::new(name).extension().is_none() {
format!("{name}.{ext}")
} else {
name.to_string()
};
self.save_dialog_filename = filename.clone();
let i = self.active_tab;
// Native: hand the destination choice to the OS save dialog —
// it provides folder browsing and overwrite confirmation. The
// chosen version rides in `save_dialog_format` and the write
// happens in `on_save_dialog_path_picked` once a path returns.
#[cfg(not(target_arch = "wasm32"))]
{
let seed_dir = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let default_name = filename;
let (filter_label, filter_ext): (&str, &str) =
if ext.eq_ignore_ascii_case("dxf") {
("DXF Files", "dxf")
} else {
("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, version);
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<std::path::PathBuf>,
) -> Task<Message> {
let Some(path) = picked else {
// Cancelled the OS dialog — if this came from the unsaved-changes →
// save → close flow, re-show the confirmation so the tab isn't lost.
if self.save_dialog_for_unsaved && self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
return Task::none();
};
let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format);
let i = self.active_tab;
self.prepare_native_save(i);
let continuation = if self.save_dialog_for_unsaved {
match self.pending_close {
Some(crate::app::PendingClose::Tab(_)) => crate::app::SaveContinuation::CloseTab,
Some(crate::app::PendingClose::Quit) => crate::app::SaveContinuation::Quit,
None => crate::app::SaveContinuation::None,
}
} else {
crate::app::SaveContinuation::None
};
self.queue_native_save(
i,
path,
version,
crate::app::SavePurpose::SaveAs,
continuation,
true,
)
}
/// AEC-drop warning → "Save anyway": accept the loss and proceed with the
/// format the user already chose.
pub(super) fn on_aec_drop_proceed(&mut self) -> Task<Message> {
self.aec_drop_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// AEC-drop warning → "Save in source version": switch the target to the
/// document's 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<Message> {
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 `<file>.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<Message> {
let mut tasks = Vec::new();
for i in 0..self.tabs.len() {
if !self.tabs[i].dirty
|| self.active_save_jobs.contains_key(&self.tabs[i].id)
{
continue;
}
let version = self.tabs[i].scene.document.version;
let target = self.autosave_target(i);
tasks.push(self.queue_native_save(
i,
target,
version,
crate::app::SavePurpose::Autosave,
crate::app::SaveContinuation::None,
false,
));
}
Task::batch(tasks)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
Task::none()
}
/// Delete the `.sv$` autosave recovery files for all open drawings. They
/// exist only to survive a crash, so a clean save or exit removes them.
pub(in crate::app) fn cleanup_autosaves(&self) {
#[cfg(not(target_arch = "wasm32"))]
for i in 0..self.tabs.len() {
let _ = std::fs::remove_file(self.autosave_target(i));
}
}
/// Remove the autosave recovery files, then quit the application.
pub(in crate::app) fn exit_app(&self) -> Task<Message> {
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<Message> {
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<f32> = wires
.iter()
.flat_map(|w| w.points.iter().map(|p| p[0]))
.filter(|v| !v.is_nan())
.collect();
let all_y: Vec<f32> = 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),
};
let plot_style = self.active_plot_style.clone();
let worker_path = path.clone();
background_task(
move || {
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,
&worker_path,
plot_style.as_ref(),
)
.map(|_| format!("Exported: {}", worker_path.display()))
.map_err(|e| format!("Export failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
)
}
/// 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<Message> {
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()
.iter()
.filter(|w| {
w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0
})
.cloned()
.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 plot_style = self.active_plot_style.clone();
let worker_path = path.clone();
self.close_active_modal();
background_task(
move || {
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,
&worker_path,
plot_style.as_ref(),
)
.map(|_| {format!(
"Plotted window to {}",
worker_path
.file_name().unwrap_or_default().to_string_lossy()
)
}).map_err(|e| format!("Plot failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
)
}
/// 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,
) -> (
std::sync::Arc<Vec<crate::scene::WireModel>>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
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<Message> {
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…");
background_task(
move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires(
wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg, plot_style,
))
},
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<acadrust::Handle>,
) -> Task<Message> {
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<acadrust::Handle> = handles.into_iter().collect();
// Union the AABBs of the picked entities' wires (world XY), matched by
// each wire's handle.
let (x0, y0, x1, y1, any) = {
let scene = &self.tabs[i].scene;
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = f32::NEG_INFINITY;
let mut y1 = f32::NEG_INFINITY;
let mut any = false;
for w in scene.entity_wires().iter() {
let picked = crate::scene::Scene::handle_from_wire_name(&w.name)
.is_some_and(|h| set.contains(&h));
if !picked {
continue;
}
let [ax0, ay0, ax1, ay1] = w.aabb;
if ax0.is_finite() && ay0.is_finite() && ax1.is_finite() && ay1.is_finite() {
x0 = x0.min(ax0);
y0 = y0.min(ay0);
x1 = x1.max(ax1);
y1 = y1.max(ay1);
any = true;
}
}
(x0, y0, x1, y1, any)
};
if !any {
self.command_line
.push_error("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<Message> {
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 (<none> / <previous> / layouts / named setups) and
// snapshot the just-loaded settings as the `<previous>` restore point.
self.refresh_page_setups();
self.plot_prev = Some(self.plot_dialog.clone());
// Default selection: model space → <none>; 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<Message> {
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;
// <none> / <previous>, then layouts (`*name*`), then named setups.
let mut list = vec![SETUP_NONE.to_string(), SETUP_PREV.to_string()];
list.extend(scene.layout_names().into_iter().map(|n| format!("*{n}*")));
list.extend(scene.page_setup_names());
self.plot_dialog.page_setups = list;
}
/// Apply a page-setup list selection to the editor. Handles the pseudo
/// entries (`<none>` / `<previous>`), layout rows (`*name*`) and named
/// setups.
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 = "".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::<f64>().unwrap_or(0.0);
ps.origin_y = d.offset_y.parse::<f64>().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::<f64>().unwrap_or(0.0);
let off_y = d.offset_y.parse::<f64>().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<Message> {
// 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");
return background_task(
move || {
crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp,
plot_style.as_ref(),
).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, true),
);
}
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 opts = self.plot_print_options(&d);
return background_task(
move || {
crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp,
plot_style.as_ref(),
).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts))
.map(|printer| format!("Sent to printer: {printer}"))
.map_err(|e| format!("Print failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, false),
);
}
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");
return background_task(
move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
eff_w,
eff_h,
ox,
oy,
rot,
1.0,
None,
&tmp,
plot_style.as_ref(),
).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}"))
},
|result| Message::BackgroundIoFinished(result, true),
);
}
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…");
background_task(
move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires_with(
wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot, plot_style, opts,
))
},
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::<u32>().unwrap_or(1).max(1),
mono: d.mono,
quality: Some(d.quality.clone()),
dpi: d.dpi.trim().parse::<u32>().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<crate::scene::WireModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
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()
.iter()
.filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0)
.cloned()
.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<Message> {
let aci = self.plotstyle_panel_aci as usize;
if let Some(table) = self.active_plot_style.as_mut() {
if let Some(entry) = table.aci_entries.get_mut(aci) {
// Parse color.
let color_str = self.ps_color_buf.trim();
if color_str.is_empty() {
entry.color = None;
} else if color_str.starts_with('#') && color_str.len() == 7 {
let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0);
let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0);
entry.color = Some([r, g, b]);
}
if let Ok(lw) = self.ps_lineweight_buf.trim().parse::<u8>() {
entry.lineweight = lw;
}
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
entry.screening = sc.min(100);
}
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::<u8>() {
entry.lineweight = lw;
}
if let Ok(sc) = self.ps_screening_buf.trim().parse::<u8>() {
entry.screening = sc.min(100);
}
}
self.active_plot_style = Some(table);
self.command_line
.push_output(&format!("Created new CTB table, ACI {aci} updated."));
}
Task::none()
}
pub(super) fn on_plot_style_panel_save(&mut self) -> Task<Message> {
if self.active_plot_style.is_none() {
self.command_line
.push_error("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,
)
}
}