2431 lines
106 KiB
Rust
2431 lines
106 KiB
Rust
//! `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 = "0°".into();
|
||
d.scale = "Fit".into();
|
||
} else if name == SETUP_PREV {
|
||
if let Some(prev) = self.plot_prev.clone() {
|
||
self.plot_dialog.copy_settings_from(&prev);
|
||
}
|
||
} else if is_layout_entry(name) {
|
||
if let Some(ps) = self
|
||
.tabs[self.active_tab]
|
||
.scene
|
||
.plot_settings_for(layout_entry_name(name))
|
||
{
|
||
self.load_plotsettings_into_dialog(&ps);
|
||
}
|
||
} else if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(name) {
|
||
self.load_plotsettings_into_dialog(&ps);
|
||
}
|
||
}
|
||
|
||
/// A page-setup name based on `base` that isn't already taken (`base`,
|
||
/// `base 2`, `base 3`, …).
|
||
fn next_page_setup_name(&self, base: &str) -> String {
|
||
let existing = self.tabs[self.active_tab].scene.page_setup_names();
|
||
if !existing.iter().any(|n| n == base) {
|
||
return base.to_string();
|
||
}
|
||
(2..)
|
||
.map(|i| format!("{base} {i}"))
|
||
.find(|c| !existing.iter().any(|n| n == c))
|
||
.unwrap_or_else(|| base.to_string())
|
||
}
|
||
|
||
/// Build a `PlotSettings` from the current dialog fields (for saving a named
|
||
/// page setup).
|
||
fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings {
|
||
use acadrust::objects::{
|
||
PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType,
|
||
};
|
||
use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize};
|
||
let d = &self.plot_dialog;
|
||
let paper = match d.paper.as_str() {
|
||
"A3" => PaperSize::A3,
|
||
"A2" => PaperSize::A2,
|
||
"A1" => PaperSize::A1,
|
||
"A0" => PaperSize::A0,
|
||
_ => PaperSize::A4,
|
||
};
|
||
let orient = if d.orientation == "Portrait" {
|
||
Orientation::Portrait
|
||
} else {
|
||
Orientation::Landscape
|
||
};
|
||
let (w, h) = sheet_mm(paper, orient);
|
||
let mut ps = PlotSettings::new("");
|
||
ps.paper_width = w;
|
||
ps.paper_height = h;
|
||
ps.paper_units = PlotPaperUnits::Millimeters;
|
||
ps.plot_type = match d.area.as_str() {
|
||
"Window" => PlotType::Window,
|
||
"Layout" => PlotType::Layout,
|
||
_ => PlotType::Extents,
|
||
};
|
||
ps.flags.plot_centered = d.center;
|
||
ps.origin_x = d.offset_x.parse::<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,
|
||
)
|
||
}
|
||
}
|