Files
OneCAD-Draft/src/app/update/file.rs
T

4896 lines
201 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::{
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)
}
}
fn plot_dialog_scale_factor(d: &crate::ui::window::plot::PlotDialogState) -> f64 {
d.scales
.iter()
.find(|(name, _)| name == &d.scale)
.map(|(_, factor)| *factor)
.or_else(|| {
let (paper, drawing) = parse_plot_scale(&d.scale);
(paper > 0.0 && drawing > 0.0).then_some(paper / drawing)
})
.unwrap_or(1.0)
.max(1e-9)
}
fn scale_name_for_factor(scales: &[(String, f64)], factor: f64) -> Option<String> {
scales
.iter()
.find(|(_, candidate)| {
(*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0)
})
.map(|(name, _)| name.clone())
}
fn plot_render_mode_override(
d: &crate::ui::window::plot::PlotDialogState,
) -> Option<acadrust::entities::ViewportRenderMode> {
use acadrust::entities::ViewportRenderMode as Mode;
match d.shade.as_str() {
"2D Wireframe" => Some(Mode::Wireframe2D),
"3D Wireframe" => Some(Mode::Wireframe3D),
"Hidden Line" => Some(Mode::HiddenLine),
"Flat Shaded" => Some(Mode::FlatShaded),
"Gouraud Shaded" => Some(Mode::GouraudShaded),
"Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges),
"Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges),
_ => None,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool {
match (
std::fs::canonicalize(left),
std::fs::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
type LayoutPlotParams = (
std::sync::Arc<Vec<crate::io::pdf_export::PlotWire>>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
crate::io::pdf_export::PlotGroupSplits,
f64,
f64,
f64,
f64,
i32,
f32,
Option<(f32, f32, f32, f32)>,
);
type ClippedPlotParams = (
Vec<crate::io::pdf_export::PlotWire>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
crate::io::pdf_export::PlotGroupSplits,
f64,
f64,
f64,
f64,
i32,
f32,
Option<(f32, f32, f32, f32)>,
);
fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) {
use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize};
let orientation = if d.orientation == "Portrait" {
Orientation::Portrait
} else {
Orientation::Landscape
};
let standard = match d.paper.as_str() {
"A3" => Some(PaperSize::A3),
"A2" => Some(PaperSize::A2),
"A1" => Some(PaperSize::A1),
"A0" => Some(PaperSize::A0),
"A4" => Some(PaperSize::A4),
"Letter" => Some(PaperSize::Letter),
"Legal" => Some(PaperSize::Legal),
"Tabloid" => Some(PaperSize::Tabloid),
_ => None,
};
if let Some(paper) = standard {
return sheet_mm(paper, orientation);
}
let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0);
let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0);
match orientation {
Orientation::Portrait => (short, long),
Orientation::Landscape => (long, short),
}
}
fn plot_content_extents(
wires: &[crate::io::pdf_export::PlotWire],
hatches: &[crate::scene::model::hatch_model::HatchModel],
wipeouts: &[crate::scene::model::hatch_model::HatchModel],
) -> Option<(f64, f64, f64, f64)> {
let mut bounds = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
let mut include = |x: f64, y: f64| {
if x.is_finite() && y.is_finite() {
bounds.0 = bounds.0.min(x);
bounds.1 = bounds.1.min(y);
bounds.2 = bounds.2.max(x);
bounds.3 = bounds.3.max(y);
}
};
for wire in wires {
let [x0, y0, x1, y1] = wire.aabb;
include(x0 as f64, y0 as f64);
include(x1 as f64, y1 as f64);
}
for hatch in wipeouts.iter().chain(hatches.iter()) {
for &[x, y] in hatch.boundary.iter() {
include(hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64);
}
}
(bounds.0.is_finite()
&& bounds.1.is_finite()
&& bounds.2.is_finite()
&& bounds.3.is_finite()
&& bounds.2 > bounds.0
&& bounds.3 > bounds.1)
.then_some(bounds)
}
fn plot_scene_content(
scene: &crate::scene::Scene,
paper_space_last: bool,
render_mode_override: Option<acadrust::entities::ViewportRenderMode>,
) -> (
std::sync::Arc<Vec<crate::io::pdf_export::PlotWire>>,
Vec<crate::scene::model::hatch_model::HatchModel>,
Vec<crate::scene::model::hatch_model::HatchModel>,
crate::io::pdf_export::PlotGroupSplits,
) {
let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override);
let plot_viewport_borders = scene
.effective_plot_settings()
.is_none_or(|settings| settings.flags.plot_viewport_borders);
paper_wires.retain(|wire| {
wire.plot_visible
&& (plot_viewport_borders
|| !crate::scene::Scene::handle_from_wire_name(&wire.name)
.and_then(|handle| scene.document.get_entity(handle))
.is_some_and(|entity| {
matches!(
entity,
acadrust::EntityType::Viewport(viewport)
if !crate::scene::Scene::is_sheet_viewport(
&scene.document,
viewport,
)
)
}))
});
model_wires.retain(|wire| wire.plot_visible);
let with_depth = |wires: Vec<crate::scene::WireModel>| {
let depths = scene.plot_wire_depths(&wires);
wires
.into_iter()
.zip(depths)
.map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth })
.collect::<Vec<_>>()
};
let paper_wires = with_depth(paper_wires);
let model_wires = with_depth(model_wires);
let paper_hatches = scene.paper_plot_hatches().as_ref().clone();
let paper_wipeouts = scene.paper_plot_wipeouts().as_ref().clone();
if scene.current_layout == "Model" {
let splits = crate::io::pdf_export::PlotGroupSplits {
wires: paper_wires.len(),
hatches: paper_hatches.len(),
wipeouts: paper_wipeouts.len(),
};
return (
std::sync::Arc::new(paper_wires),
paper_hatches,
paper_wipeouts,
splits,
);
}
let (mut model_pattern_wires, model_hatches, model_wipeouts) =
scene.viewport_plot_fills();
model_pattern_wires.retain(|(wire, _)| wire.plot_visible);
let model_pattern_wires = model_pattern_wires
.into_iter()
.map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth })
.collect::<Vec<_>>();
let (wires, hatches, wipeouts, splits) = if paper_space_last {
let splits = crate::io::pdf_export::PlotGroupSplits {
wires: model_wires.len() + model_pattern_wires.len(),
hatches: model_hatches.len(),
wipeouts: model_wipeouts.len(),
};
let mut wires = model_wires;
let mut hatches = model_hatches;
let mut wipeouts = model_wipeouts;
wires.extend(model_pattern_wires);
wires.extend(paper_wires);
hatches.extend(paper_hatches);
wipeouts.extend(paper_wipeouts);
(wires, hatches, wipeouts, splits)
} else {
let splits = crate::io::pdf_export::PlotGroupSplits {
wires: paper_wires.len(),
hatches: paper_hatches.len(),
wipeouts: paper_wipeouts.len(),
};
let mut wires = paper_wires;
let mut hatches = paper_hatches;
let mut wipeouts = paper_wipeouts;
wires.extend(model_wires);
wires.extend(model_pattern_wires);
hatches.extend(model_hatches);
wipeouts.extend(model_wipeouts);
(wires, hatches, wipeouts, splits)
};
(std::sync::Arc::new(wires), hatches, wipeouts, splits)
}
impl OpenCADStudio {
/// Persist exact ACIS bodies and kernel-derived edge caches before saving.
fn sync_solid_models_for_save(&mut self, i: usize) {
use acadrust::EntityType;
let scene = &mut self.tabs[i].scene;
let targets: Vec<(acadrust::Handle, bool, bool)> = scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Solid3D(solid) = entity else {
return None;
};
let h = solid.common.handle;
let needs_acis = !solid.acis_data.has_data();
let needs_wires = solid.wires.is_empty();
(needs_acis || needs_wires).then_some((h, needs_acis, needs_wires))
})
.collect();
for (h, needs_acis, needs_wires) in targets {
let body = scene.solid_models.get(&h);
let sat = needs_acis
.then(|| {
body.and_then(crate::scene::convert::acis_export::solid_to_sat)
})
.flatten();
let wires = needs_wires.then(|| {
if let Some(body) = body {
return crate::scene::model::solid_model::edge_wires(body);
}
let Some(mesh) = scene.meshes.get(&h).or_else(|| scene.block_meshes.get(&h)) else {
return Vec::new();
};
mesh.edge_verts
.chunks_exact(2)
.enumerate()
.map(|(index, points)| {
let first_low = mesh
.edge_verts_low
.get(index * 2)
.copied()
.unwrap_or([0.0; 3]);
let second_low = mesh
.edge_verts_low
.get(index * 2 + 1)
.copied()
.unwrap_or([0.0; 3]);
acadrust::entities::Wire::from_points(vec![
acadrust::types::Vector3::new(
points[0][0] as f64 + first_low[0] as f64,
points[0][1] as f64 + first_low[1] as f64,
points[0][2] as f64 + first_low[2] as f64,
),
acadrust::types::Vector3::new(
points[1][0] as f64 + second_low[0] as f64,
points[1][1] as f64 + second_low[1] as f64,
points[1][2] as f64 + second_low[2] as f64,
),
])
})
.collect()
});
if let Some(EntityType::Solid3D(solid)) = scene.document.get_entity_mut(h) {
if let Some(sat) = sat {
solid.set_sat_document(&sat);
}
if let Some(wires) = wires.filter(|wires| !wires.is_empty()) {
solid.wires = wires;
}
}
}
}
/// Snapshot the persisted UI preferences from live state.
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
crate::app::settings::UserSettings {
dyn_input: self.dyn_input,
polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg,
zoom_wheel_reversed: self.zoom_wheel_reversed,
zoom_factor: self.zoom_factor,
cursor_size: self.cursor_size,
pick_box: self.pick_box,
double_click_block_refedit: self.double_click_block_refedit,
double_click_block_attedit: self.double_click_block_attedit,
grip_object_limit: self.grip_object_limit,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
lineweight_display_scale: self.lineweight_display_scale,
isometric_drafting: self.isometric_drafting,
iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg,
otrack: self.snapper.otrack_enabled,
default_assoc_prompted: self.default_assoc_prompted,
donation_prompt_version: self.donation_prompt_version.clone(),
disabled_plugins: {
let mut v: Vec<String> = self.disabled_plugins.iter().cloned().collect();
v.sort();
v
},
plugin_repos: self.plugin_repos.clone(),
literal_spaces: self.command_line.literal_spaces,
command_history_height: self.command_line.history_height,
osmode: crate::app::settings::osmode_from_snaps(
self.snapper.enabled.iter(),
self.snapper.snap_enabled,
),
texteditmode: self.texteditmode,
quick_dimension_snap_priority: self.quick_dimension_snap_priority,
dimension_continue_mode: self.dimension_continue_mode,
delete_objects: self.delete_objects,
textfill: crate::scene::text::sdf_atlas::textfill(),
backup_on_save: self.backup_on_save,
file_assoc_enabled: self.file_assoc_enabled,
savetime_min: self.savetime_min,
default_save_format: self.default_save_format.clone(),
pick_add: self.pick_add,
pick_drag_rect: self.pick_drag_rect,
quick_properties: self.quick_properties,
bg_color: None,
paper_bg_color: None,
language: self.language,
cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines),
commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms(
self.commandline_fade_ms,
),
block_mru: self.block_mru.clone(),
block_freq: self.block_freq.clone(),
}
}
/// Apply restored preferences to live state.
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
self.dyn_input = s.dyn_input;
self.polar_mode = s.polar;
self.polar_increment_deg = s.polar_increment_deg;
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
self.zoom_factor = s.zoom_factor.clamp(3, 100);
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.double_click_block_refedit = s.double_click_block_refedit;
self.double_click_block_attedit = s.double_click_block_attedit;
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
self.cursor_type = s.cursor_type;
self.crosshair_color = s.crosshair_color;
self.crosshair_color_input = s
.crosshair_color
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.lineweight_display_scale = s.lineweight_display_scale.clamp(25, 200);
self.isometric_drafting = s.isometric_drafting;
self.iso_plane = s.iso_plane;
self.snap_angle_deg = if s.snap_angle_deg.is_finite() {
s.snap_angle_deg.rem_euclid(360.0)
} else {
0.0
};
// Ortho + running OSNAP are per-drawing (adopted from the header on
// open / tab switch), not app-global, so they are not applied here.
self.snapper.otrack_enabled = s.otrack;
self.default_assoc_prompted = s.default_assoc_prompted;
self.donation_prompt_version = s.donation_prompt_version.clone();
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
self.plugin_repos = s.plugin_repos.clone();
self.command_line.literal_spaces = s.literal_spaces;
self.command_line.history_height = if s.command_history_height.is_finite() {
s.command_history_height.clamp(
crate::ui::command_line::HISTORY_HEIGHT_MIN,
crate::ui::command_line::HISTORY_HEIGHT_MAX,
)
} else {
crate::ui::command_line::HISTORY_HEIGHT_DEFAULT
};
let (modes, snap_enabled) = crate::app::settings::snaps_from_osmode(s.osmode);
self.snapper.enabled = modes.into_iter().collect();
self.snapper.snap_enabled = snap_enabled;
self.texteditmode = s.texteditmode;
self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1);
self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1);
self.delete_objects = s.delete_objects.clamp(0, 3);
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
self.backup_on_save = s.backup_on_save;
self.file_assoc_enabled = s.file_assoc_enabled;
self.savetime_min = s.savetime_min;
self.default_save_format =
crate::io::canonical_save_format(&s.default_save_format).to_string();
self.pick_add = s.pick_add;
self.pick_drag_rect = s.pick_drag_rect;
self.quick_properties = s.quick_properties;
// Legacy settings.bg_color / paper_bg_color are superseded by model_space config.
if crate::i18n::set_language(s.language).is_ok() {
self.language = s.language;
}
self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines);
self.command_line
.set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8);
self.commandline_fade_ms =
crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms);
self.command_line.set_commandline_fade_ms(
self.commandline_fade_ms.clamp(0, 60000) as u32,
);
// Block usage: clone but cap to sane sizes (MRU 20, freq map 200)
self.block_mru = s.block_mru.iter().take(20).cloned().collect();
self.block_freq = s
.block_freq
.iter()
.take(200)
.map(|(k, v)| (k.clone(), *v))
.collect();
// Push restored display defaults onto every drawing tab that exists now.
// Tabs created later pick them up at their construction site.
for idx in 0..self.tabs.len() {
self.apply_display_defaults(idx);
}
self.rebuild_ribbon_modules();
}
pub(crate) fn record_block_insert(&mut self, name: &str) {
let key = name.to_ascii_uppercase();
*self.block_freq.entry(key).or_insert(0) += 1;
self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name));
self.block_mru.insert(0, name.to_string());
if self.block_mru.len() > 20 {
self.block_mru.truncate(20);
}
// Cap freq map to 200 most frequent to bound persistence size.
if self.block_freq.len() > 200 {
let mut items: Vec<(String, u32)> = self
.block_freq
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
items.truncate(200);
self.block_freq = items.into_iter().collect();
}
// Debounce disk writes: at most once per second (2.4) to avoid thrash
// during rapid scripting/batch inserts. Immediate first write ensures
// crash recovery. Wasm has no `std::time::Instant` guarantee, so persist
// immediately there.
#[cfg(not(target_arch = "wasm32"))]
{
let now = std::time::Instant::now();
let should_persist = match self.block_usage_last_persist {
None => true,
Some(t) => now.duration_since(t).as_secs_f32() >= 1.0,
};
if should_persist {
self.block_usage_last_persist = Some(now);
self.persist_settings_if_changed();
}
}
#[cfg(target_arch = "wasm32")]
{
self.persist_settings_if_changed();
}
}
pub(crate) fn block_usage_snapshot(&self) -> rustc_hash::FxHashMap<String, (u32, usize)> {
let mut out = rustc_hash::FxHashMap::default();
for (idx, name) in self.block_mru.iter().enumerate() {
let up = name.to_ascii_uppercase();
let freq = self.block_freq.get(&up).copied().unwrap_or(0);
out.insert(up, (freq, idx));
}
// Ensure every freq entry has at least an entry (for blocks not in MRU)
for (up, freq) in &self.block_freq {
out.entry(up.clone()).or_insert((*freq, usize::MAX));
}
out
}
pub(crate) fn ranked_block_names(&self, names: &[String]) -> Vec<String> {
let mut scored: Vec<(&String, u32, usize)> = names
.iter()
.map(|n| {
let up = n.to_ascii_uppercase();
let f = self.block_freq.get(&up).copied().unwrap_or(0);
let m = self
.block_mru
.iter()
.position(|x| x.eq_ignore_ascii_case(n))
.unwrap_or(usize::MAX);
(n, f, m)
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)).then_with(|| a.0.cmp(b.0)));
scored.into_iter().map(|(n, _, _)| n.clone()).collect()
}
/// Adopt the per-drawing sysvars stored in tab `i`'s document header —
/// Ortho (`$ORTHOMODE`) and the running object-snap set (`$OSMODE`) — into
/// the live app state, so each drawing keeps its own. Called when a drawing
/// becomes active (open, tab switch). The app-settings values seed the boot
/// default; the per-file header overrides it here.
pub(in crate::app) fn adopt_header_sysvars(&mut self, i: usize) {
if self.tabs[i].is_start {
return;
}
let (ortho, osmode) = {
let h = &self.tabs[i].scene.document.header;
(h.ortho_mode, h.object_snap_mode)
};
self.ortho_mode = ortho;
// Ortho and Polar are mutually exclusive (ToggleOrtho clears Polar).
if ortho {
self.polar_mode = false;
}
// OSMODE has no file slot in modern DWG (R2000+ moved it to the
// registry), so a header value of 0 just means "absent" — keep the
// user's app-level set instead of wiping it. Only a legacy R13/R14 or
// DXF file that really carries a nonzero mask overrides.
if osmode != 0 {
let (modes, snap_enabled) = crate::app::settings::snaps_from_osmode(osmode);
self.snapper.enabled = modes.into_iter().collect();
self.snapper.snap_enabled = snap_enabled;
}
}
/// Stamp the live per-drawing sysvars (Ortho, running OSNAP) onto tab `i`'s
/// document header just before it is written to disk (or before switching
/// away from it), so they persist with the drawing.
pub(in crate::app) fn stamp_header_sysvars(&mut self, i: usize) {
if self.tabs[i].is_start {
return;
}
let osmode = crate::app::settings::osmode_from_snaps(
self.snapper.enabled.iter(),
self.snapper.snap_enabled,
);
let h = &mut self.tabs[i].scene.document.header;
h.ortho_mode = self.ortho_mode;
h.object_snap_mode = osmode;
}
/// Apply persisted viewport display defaults to tab `idx`.
pub(in crate::app) fn apply_display_defaults(&mut self, idx: usize) {
let bg = self.default_bg_color;
let paper_bg = self.default_paper_bg_color;
let tab = &mut self.tabs[idx];
if tab.is_start {
return;
}
tab.scene.model_lineweight_scale = self.lineweight_display_scale as f32 / 100.0;
if bg.is_none() && paper_bg.is_none() {
return;
}
if let Some(c) = bg {
tab.bg_color = Some(c);
tab.scene.bg_color = c;
}
if let Some(c) = paper_bg {
tab.paper_bg_color = Some(c);
tab.scene.paper_bg_color = c;
}
tab.scene.recolor_meshes();
tab.scene.bump_geometry();
}
/// Check if a suspended command exists on the active tab and resume it
/// with the outcome of the text editor.
pub(in crate::app) fn post_editor_closed(&mut self, committed: bool) -> Task<Message> {
self.reset_modal_geometry();
let i = self.active_tab;
if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() {
if committed {
if let Some(value) = self.pending_command_editor_text.take() {
cmd.on_editor_text(value);
}
} else {
self.pending_command_editor_text = None;
}
let res = cmd.on_editor_closed(committed);
self.tabs[i].active_cmd = Some(cmd);
self.apply_cmd_result(res)
} else {
self.focus_cmd_input()
}
}
/// Rebuild the ribbon's tab list from the registry, dropping the tabs of any
/// disabled plugins. Call after `disabled_plugins` changes.
pub(in crate::app) fn rebuild_ribbon_modules(&mut self) {
let modules =
crate::plugin::ribbon_modules_enabled(&self.disabled_plugins);
self.ribbon.set_modules(modules);
// Refresh the command-line autocomplete pool so a newly enabled plugin's
// commands become typeable (and a disabled one's drop out). This runs on
// startup load, settings reload, and every enable/disable toggle (#272).
self.command_line.dynamic_commands =
crate::plugin::plugin_command_names(&self.disabled_plugins);
}
/// Snapshot of disabled plugin ids — lets the registry skip them while it
/// holds a `&mut` borrow of the app via `HostSession`.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn disabled_plugin_ids(&self) -> rustc_hash::FxHashSet<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 releases and their
/// manifest API versions. The fetch runs on its own OS thread because the
/// several sequential HTTP requests inside `fetch_release_info` would
/// otherwise block the async executor and serialise all repo fetches.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
let label = repo.clone();
Task::perform(
async move {
let (tx, rx) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = crate::plugin::marketplace::fetch_release_info(&repo);
let _ = tx.send(result);
});
rx.await
.unwrap_or_else(|_| Err("release fetch thread died".into()))
},
move |res| Message::PluginReleasesFetched(label, res),
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
Task::done(Message::PluginReleasesFetched(
repo,
Err("External plugins are available in the desktop app.".to_string()),
))
}
/// Background task: fetch a repository README from its default branch.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn fetch_plugin_readme_task(&self, repo: String) -> Task<Message> {
let label = repo.clone();
Task::perform(
async move { crate::plugin::marketplace::fetch_readme(&repo) },
move |result| Message::PluginReadmeFetched(label, result),
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn fetch_plugin_readme_task(&self, repo: String) -> Task<Message> {
Task::done(Message::PluginReadmeFetched(
repo,
Err("Plugin details are available in the desktop app.".to_string()),
))
}
/// Background task: download and install the `tag` release of `owner/repo`.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn install_task(&self, repo: String, tag: String) -> Task<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, &repo)
},
Message::PluginInstalled,
)
}
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn install_task(&self, _repo: String, _tag: String) -> Task<Message> {
Task::done(Message::PluginInstalled(Err(
"External plugins are available in the desktop app.".to_string(),
)))
}
/// Gather the full persisted config (all sections) from live app state.
pub(in crate::app) fn current_config(&self) -> crate::app::config::AppConfig {
crate::app::config::AppConfig {
settings: self.current_settings(),
theme: self.ui_theme.clone(),
recent: crate::app::config::RecentConfig {
files: self
.recent_files
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
limit: self.recent_limit,
},
start: crate::app::config::StartConfig {
section: self.start_section,
},
statusbar: self.statusbar_config.clone(),
dock: {
let mut dock = self.dock.clone();
dock.ensure_settings();
dock
},
annotation_auto_scale: self.annotation_auto_scale,
ribbon: crate::app::config::RibbonConfig {
collapse: self.ribbon.collapse_mode(),
},
plot: self.plot_dialog.clone(),
shortcuts: crate::app::config::ShortcutConfig {
bindings: self
.shortcut_bindings
.iter()
.map(|(key, command)| (key.clone(), command.clone()))
.collect(),
},
model_space: self.model_space.clone(),
}
}
/// Distribute a loaded config into live app state (called once at startup).
pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) {
self.apply_settings(&cfg.settings);
self.ui_theme = cfg.theme.clone();
self.active_theme = self.ui_theme.to_iced();
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.model_space = cfg.model_space;
self.model_bg_input = self
.model_space
.custom_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.paper_bg_input = self
.model_space
.custom_paper_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.desk_bg_input = self
.model_space
.custom_desk_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.sync_model_space_theme(false);
self.recent_files = cfg
.recent
.files
.iter()
.map(std::path::PathBuf::from)
.collect();
self.recent_limit = cfg
.recent
.limit
.clamp(crate::app::recent::RECENT_MIN, crate::app::recent::RECENT_MAX);
self.recent_files.truncate(self.recent_limit);
self.recent_limit_input = self.recent_limit.to_string();
// Thumbnails are decoded by a background task queued at boot
// (`refresh_recent_thumbs`) — never here on the boot path.
self.start_section = cfg.start.section;
self.statusbar_config = cfg.statusbar;
let mut dock = cfg.dock;
dock.ensure_settings();
self.dock = dock;
self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4);
self.ribbon.set_collapse_mode(cfg.ribbon.collapse);
self.plot_dialog = cfg.plot;
self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect();
self.shortcut_bindings
.entry("F5".to_string())
.or_insert_with(|| "ISOPLANE".to_string());
}
/// Write the config only when it changed since the last write, so a toggle
/// persists immediately without thrashing native or browser storage.
pub(in crate::app) fn save_config(&mut self) {
let cur = self.current_config();
if self.last_saved_config.as_ref() != Some(&cur) {
cur.save();
self.last_saved_config = Some(cur);
}
}
/// Back-compat name for the many "a preference changed, persist it" sites.
pub(in crate::app) fn persist_settings_if_changed(&mut self) {
// Keep resize feedback live without writing settings on every pointer
// move. The release message saves the final height once.
if !self.command_history_resizing {
self.save_config();
}
}
/// Sync the active theme and model space configuration into all open tabs
/// and the application default background settings.
pub(in crate::app) fn sync_model_space_theme(&mut self, geometry_changed: bool) {
let model_bg = self.model_space.resolve_model_bg(&self.active_theme);
let paper_bg = self.model_space.resolve_paper_bg();
let sel_color = self.model_space.resolve_selection_color();
let sel_effect = self.model_space.selection_effect;
self.default_bg_color = Some(model_bg);
self.default_paper_bg_color = Some(paper_bg);
for tab in &mut self.tabs {
let old_bg = tab.scene.bg_color;
let old_paper = tab.scene.paper_bg_color;
let old_sel_color = tab.scene.selection_color;
let old_sel_effect = tab.scene.selection_effect;
tab.scene.bg_color = model_bg;
tab.scene.paper_bg_color = paper_bg;
tab.scene.selection_color = sel_color;
tab.scene.selection_effect = sel_effect;
tab.bg_color = Some(model_bg);
tab.paper_bg_color = Some(paper_bg);
if old_sel_color != sel_color || old_sel_effect != sel_effect {
tab.scene.selection_generation = tab.scene.selection_generation.wrapping_add(1);
}
if geometry_changed || old_bg != model_bg || old_paper != paper_bg {
tab.scene.recolor_meshes();
tab.scene.bump_geometry();
}
tab.scene.request_refresh(crate::scene::ViewportRefreshScope::All);
}
}
/// Record that the one-time default-association prompt has been answered and
/// flush it to disk, so the dialog never reappears on later launches.
pub(in crate::app) fn mark_assoc_prompted(&mut self) {
self.default_assoc_prompted = true;
self.persist_settings_if_changed();
}
pub(super) fn on_open_file(&mut self) -> Task<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,
));
let open_id = self.next_open_id();
self.opening = Some(crate::app::OpenProgress {
id: open_id,
name: "Opening…".into(),
source_path: None,
size_bytes: 0,
state: state.clone(),
started: Instant::now(),
recovery_error: None,
recovery_read_stats: None,
recovery_bytes: None,
});
Task::perform(crate::io::pick_and_load_web(state), move |outcome| {
Message::WebFileOpened(open_id, outcome)
})
}
}
pub(in crate::app) fn next_open_id(&mut self) -> u64 {
self.open_job_serial = self.open_job_serial.wrapping_add(1).max(1);
self.open_job_serial
}
/// Index of a tab already showing `path`, or `None`.
///
/// Compares resolved paths, so the same drawing reached through a symlink,
/// a `..` segment or a different relative spelling is recognised as the one
/// already open rather than loaded a second time. A path that cannot be
/// resolved (deleted since) matches nothing and falls through to the normal
/// open, which reports the miss.
pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option<usize> {
#[cfg(target_arch = "wasm32")]
{
return self
.tabs
.iter()
.position(|tab| tab.current_path.as_deref() == Some(path));
}
#[cfg(not(target_arch = "wasm32"))]
{
let want = std::fs::canonicalize(path).ok()?;
self.tabs.iter().position(|t| {
t.current_path
.as_deref()
.and_then(|p| std::fs::canonicalize(p).ok())
.is_some_and(|p| p == want)
})
}
}
/// Read through this session's lease when it covers `path`.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn read_drawing(
&self,
path: &std::path::Path,
) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let lease = self
.tab_showing(path)
.and_then(|i| self.tabs[i].edit_lease.as_ref());
let leased = match lease {
Some(lease) => lease.reader()?,
None => None,
};
let Some(mut reader) = leased else {
return std::fs::read(path);
};
reader.seek(SeekFrom::Start(0))?;
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
Ok(bytes)
}
/// Start the next drawing a second launch handed us, if any.
///
/// Must be called from EVERY path that clears `opening` — completion, error
/// and cancel alike. Draining only the success path would strand the queue
/// forever the first time a file fails to parse.
pub(in crate::app) fn drain_pending_open(&mut self) -> Task<Message> {
match self.pending_opens.pop_front() {
Some(p) => Task::done(Message::OpenExternal(p)),
None => Task::none(),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn install_native_edit_guard(
&mut self,
i: usize,
path: &std::path::Path,
loaded_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
) {
let current_fingerprint =
crate::io::edit_lock::FileFingerprint::capture(path).ok();
if loaded_fingerprint
.as_ref()
.zip(current_fingerprint.as_ref())
.is_some_and(|(loaded, current)| loaded != current)
{
self.command_line.push_error_once(
crate::t!(
"Drawing changed on disk while it was opening; Save will require conflict resolution."
)
.as_ref(),
);
}
self.tabs[i].disk_fingerprint =
loaded_fingerprint.or(current_fingerprint);
match crate::io::edit_lock::EditLease::acquire(path) {
Ok(lease) => {
if let Some(warning) = lease.platform_warning() {
self.command_line.push_info(crate::tf!(
"Edit lease active; {warning}. External-change checks remain active."
).as_ref());
}
self.tabs[i].edit_lease = Some(lease);
self.tabs[i].edit_lock_conflict = false;
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lock_conflict = true;
self.command_line.push_error_once(crate::tf!(
"Opened read-only against other editors: {error}"
).as_ref());
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(crate::tf!(
"{error}. External-change checks remain active."
).as_ref());
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn refresh_native_edit_guard_after_save(
&mut self,
i: usize,
path: &std::path::Path,
path_changed: bool,
destination_lease: Option<crate::io::edit_lock::EditLease>,
) {
if path_changed {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lease = destination_lease;
self.tabs[i].edit_lock_conflict = false;
if self.tabs[i].edit_lease.is_none() {
self.install_native_edit_guard(i, path, None);
return;
}
}
let refresh = self.tabs[i].edit_lease.as_mut().map(|lease| {
lease
.refresh_drawing_lock(path)
.map(|_| lease.platform_warning().map(str::to_owned))
});
match refresh {
Some(Ok(warning)) => {
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(crate::tf!(
"{warning}. External-change checks remain active."
).as_ref());
}
}
Some(Err(crate::io::edit_lock::EditLeaseError::Locked(error))) => {
self.tabs[i].edit_lock_conflict = true;
self.command_line.push_error_once(crate::tf!(
"Saved, but the refreshed drawing is locked by another editor: {error}"
).as_ref());
}
Some(Err(crate::io::edit_lock::EditLeaseError::Unavailable(error))) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(crate::tf!(
"{error}. External-change checks remain active."
).as_ref());
}
None => self.install_native_edit_guard(i, path, None),
}
self.tabs[i].disk_fingerprint =
crate::io::edit_lock::FileFingerprint::capture(path).ok();
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn save_tab_synchronously_protected(
&mut self,
i: usize,
path: std::path::PathBuf,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
let previous_autosave = self.autosave_target(i);
if self.tabs[i].recovery_save_as_required
&& self.tabs[i]
.current_path
.as_deref()
.is_some_and(|source| native_paths_match(source, &path))
{
return Err(crate::io::SaveFailure::other(
"repaired drawing must be saved to a new file",
));
}
let path_changed = self.tabs[i]
.current_path
.as_deref()
.is_none_or(|current| !native_paths_match(current, &path));
if !path_changed && self.tabs[i].edit_lock_conflict {
return Err(crate::io::SaveFailure::file_in_use(
"drawing edit lock is held by another editor",
));
}
let mut destination_lease = if path_changed {
match crate::io::edit_lock::EditLease::acquire(&path) {
Ok(lease) => Some(lease),
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
return Err(crate::io::SaveFailure::file_in_use(error));
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.command_line.push_info(crate::tf!(
"{error}. External-change checks remain active."
).as_ref());
None
}
}
} else {
None
};
let expected_fingerprint = if path_changed {
None
} else {
self.tabs[i].disk_fingerprint.clone()
};
let lease = if path_changed {
destination_lease.as_mut()
} else {
self.tabs[i].edit_lease.as_mut()
};
let (expected_fingerprint, verify_reader) =
Self::native_save_verification(&path, lease, expected_fingerprint)?;
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let snapshot = self.tabs[i].scene.document.clone();
crate::io::save_owned_as_version_atomic(
snapshot,
&path,
version,
self.backup_on_save,
expected_fingerprint,
verify_reader,
)?;
if set_current_path {
self.tabs[i].current_path = Some(path.clone());
}
self.refresh_native_edit_guard_after_save(
i,
&path,
path_changed,
destination_lease,
);
self.tabs[i].dirty = false;
if set_current_path {
self.tabs[i].recovery_save_as_required = false;
}
let _ = std::fs::remove_file(previous_autosave);
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn retry_native_edit_guard(
&mut self,
i: usize,
path: &std::path::Path,
) -> Result<(), String> {
if let Some(lease) = self.tabs[i].edit_lease.as_mut() {
match lease.refresh_drawing_lock(path) {
Ok(()) => {
let warning = lease.platform_warning().map(str::to_owned);
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(crate::tf!(
"{warning}. External-change checks remain active."
).as_ref());
}
return Ok(());
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
return Err(error);
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(crate::tf!(
"{error}. External-change checks remain active."
).as_ref());
return Ok(());
}
}
}
match crate::io::edit_lock::EditLease::acquire(path) {
Ok(lease) => {
let warning = lease.platform_warning().map(str::to_owned);
self.tabs[i].edit_lease = Some(lease);
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(crate::tf!(
"{warning}. External-change checks remain active."
).as_ref());
}
Ok(())
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => Err(error),
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(crate::tf!(
"{error}. External-change checks remain active."
).as_ref());
Ok(())
}
}
}
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument,
mut caches: crate::scene::DerivedCaches,
) -> Task<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 size_bytes = self
.opening
.as_ref()
.map(|progress| progress.size_bytes)
.unwrap_or(0);
let timings = caches.timings;
let entity_count = doc.entities().count();
let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| {
stats.recovered()
|| stats.skipped_source_records > 0
|| !stats.stream_completed
}) || doc.notifications.iter().any(|item| {
item.notification_type == acadrust::notification::NotificationType::Error
});
let reference_recovered = caches
.xrefs
.iter()
.any(|item| item.status == crate::io::xref::XrefStatus::Recovered);
let reference_failed = caches
.xrefs
.iter()
.any(|item| item.status == crate::io::xref::XrefStatus::Failed);
let document_repaired = parser_errors_recovered
|| reference_recovered
|| caches.corrupt_dropped > 0
|| caches.xref_dropped > 0;
let recovery_needed = document_repaired || reference_failed;
let total_ms = open_started
.map(|started| started.elapsed().as_millis() as u32)
.unwrap_or(0);
self.command_line
.push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref());
if caches.corrupt_dropped > 0 {
self.command_line.push_error(crate::tf!(
"Warning: {} corrupt entities dropped (parser junk — bad normals / counts)",
caches.corrupt_dropped
).as_ref());
}
if caches.xref_dropped > 0 {
self.command_line.push_error(crate::tf!(
"Warning: {} corrupt xref entities dropped",
caches.xref_dropped
).as_ref());
}
for info in &caches.xrefs {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line
.push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref());
}
crate::io::xref::XrefStatus::Recovered => {
self.command_line.push_error(crate::tf!(
"XREF Recovered with warnings: \"{}\"",
info.name
).as_ref());
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(crate::tf!(
"XREF Not found: \"{}\" ({})",
info.name, info.path
).as_ref());
}
crate::io::xref::XrefStatus::Failed => {
self.command_line.push_error(crate::tf!(
"XREF Recovery failed: \"{}\" ({})",
info.name, info.path
).as_ref());
}
crate::io::xref::XrefStatus::Unloaded => {
self.command_line
.push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref());
}
}
}
#[cfg(not(target_arch = "wasm32"))]
let thumbs_task = self.push_recent(path.clone());
#[cfg(target_arch = "wasm32")]
let thumbs_task = Task::none();
let current_is_empty = {
let t = &self.tabs[self.active_tab];
!t.is_start
&& t.current_path.is_none()
&& !t.dirty
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
};
let i = if current_is_empty {
self.active_tab
} else {
self.tab_counter += 1;
let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
self.tabs.push(new_tab);
let idx = self.tabs.len() - 1;
self.active_tab = idx;
self.apply_display_defaults(idx);
idx
};
let mut recovery_report = recovery_needed.then(|| {
crate::io::recovery::RecoveryReport::recovered(
self.tabs[i].id,
&path,
size_bytes,
caches.source_sha256.clone(),
caches.read_stats.clone(),
entity_count.saturating_add(caches.corrupt_dropped),
caches.corrupt_dropped,
caches.xref_dropped,
&caches.xrefs,
&doc.notifications,
document_repaired,
timings,
total_ms,
)
});
if let Some(report) = recovery_report.as_mut() {
report.persist();
}
#[cfg(not(target_arch = "wasm32"))]
let opened_fingerprint = self
.opening
.as_ref()
.and_then(|opening| opening.fingerprint.clone());
self.tabs[i].current_path = Some(path.clone());
#[cfg(not(target_arch = "wasm32"))]
self.install_native_edit_guard(i, &path, opened_fingerprint);
self.tabs[i].scene.material_base_dir =
path.parent().map(std::path::Path::to_path_buf);
self.tabs[i].scene.document = doc;
self.tabs[i].active_layer = self.tabs[i]
.scene
.document
.header
.current_layer_name
.clone();
// A file saved without the built-in Standard styles (foreign
// or damaged) gets them re-seeded so nothing dangles (#366).
crate::app::style_ops::ensure_standard_styles(
&mut self.tabs[i].scene.document,
);
// Follow the file's saved current UCS from the moment it opens.
self.tabs[i].adopt_active_ucs_from_header();
// Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP
// ($OSMODE) so each file keeps its own state.
self.adopt_header_sysvars(i);
// Route shared CJK ideographs to the language matching this
// drawing's code page (web per-language font split). Drop the
// glyph cache if it changed so Han re-resolves to the new
// language's font; geometry is (re)built below regardless. (#141)
if crate::scene::text::web_font::set_cjk_lang_from_codepage(
&self.tabs[i].scene.document.header.code_page,
) {
crate::scene::text::ttf_glyph::clear_fallback_cache();
}
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into
// drawing units as well: metric annotation sizes are paper millimetres
// and imperial annotation sizes are paper inches.
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into
// the drawing's INSUNITS as well, so e.g. a metre drawing uses
// 0.001 model units for 1 mm of paper at 1:1.
let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value;
let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor();
self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 {
((1.0 / cannoscale_value) / unit_factor) as f32
} else {
(1.0 / unit_factor) as f32
};
// Open-time breakdown so regressions are visible immediately.
// `total` is wall time from the Open click to here (post-xref,
// pre-first-frame); the phase figures are the background-thread
// parse/purge/cache spans plus the UI-thread xref resolve.
self.command_line.push_info(crate::tf!(
" parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms",
timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms
).as_ref());
// Caches were built on the background thread inside open_path().
self.tabs[i].scene.local_extent_max = caches.local_extent_max;
self.tabs[i].scene.local_center = caches.local_center;
self.tabs[i].scene.hatches = caches.hatches;
self.tabs[i].scene.images = caches.images;
self.tabs[i].scene.meshes = caches.meshes;
self.tabs[i].scene.block_meshes = caches.block_meshes;
self.tabs[i].scene.object_data_cache = caches.object_data;
let prepared_geometry = caches.prepared_geometry.take();
// Invalidate the wire cache so the new document is tessellated.
self.tabs[i].scene.bump_geometry();
if let Some(prepared) = prepared_geometry {
self.tabs[i].scene.install_prepared_open_geometry(prepared);
}
// Pay for the interaction index here, not on the first hover.
self.tabs[i].scene.warm_interaction_caches();
self.tabs[i]
.scene
.replace_selection(rustc_hash::FxHashSet::default());
self.tabs[i].scene.preview_wires = vec![];
// Reopen in whichever space the file was saved in — the CTAB
// tab name when recorded, else the $TILEMODE model/paper flag —
// instead of always landing in Model.
{
let names = self.tabs[i].scene.layout_names();
let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document)
.and_then(|n| {
names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned()
});
self.tabs[i].scene.current_layout = match saved {
Some(n) => n,
None if self.tabs[i].scene.document.header.show_model_space => {
"Model".to_string()
}
// Paper was active but no CTAB — fall back to the first
// paper layout (names[0] is always "Model").
None => names
.into_iter()
.nth(1)
.unwrap_or_else(|| "Model".to_string()),
};
self.tabs[i].scene.load_current_layout_state();
self.tabs[i].refresh_active_ucs();
}
// Object isolation is session-only. A newly opened drawing must
// not inherit the previous tab's filter, and persisted entity
// visibility remains independent (not an isolation session).
self.tabs[i].scene.reset_transient_visibility();
crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document);
self.tabs[i].properties = PropertiesPanel::empty();
// Seed the current table / multileader style from the file's
// header so the ✓ marks the right one (text/dim/mline come from
// the document header directly). DXF provides these via
// $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard".
self.ribbon.active_table_style = self.tabs[i]
.scene
.document
.header
.current_table_style_name
.clone();
self.tabs[i].active_mleader_style = self.tabs[i]
.scene
.document
.header
.current_mleader_style_name
.clone();
let doc_layers = self.tabs[i].scene.document.layers.clone();
let vp_info = self.tabs[i].scene.viewport_list();
self.tabs[i]
.layers
.sync_with_viewports(&doc_layers, vp_info);
self.sync_ribbon_layers();
// Load the Annotate-ribbon style dropdowns (text / dimension /
// multileader / table) from the opened document instead of
// leaving them on the hard-coded "Standard" default.
self.sync_ribbon_styles();
// Reset the Home-ribbon Color / Linetype / Lineweight chips
// to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT
// defaults (or to ByLayer when the file leaves them empty).
// Without this they stick to whatever the prior tab had
// selected — see #21.
self.sync_ribbon_from_selection();
self.tabs[i].scene.restore_saved_camera();
// Grid/snap are per-drawing view settings — adopt the opened
// file's active viewport state rather than a global preference.
self.adopt_view_display(i);
self.sync_render_mode_to_active_tile(i);
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.tabs[i].dirty = document_repaired;
self.tabs[i].recovery_save_as_required = document_repaired;
self.tabs[i].history = crate::app::document::HistoryState::default();
self.refresh_properties();
#[cfg(not(target_arch = "wasm32"))]
let interaction_task = {
let wires = self.tabs[i].scene.hit_test_wires();
let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1;
self.prepare_interaction_index_task(i, wires, screen_height)
.unwrap_or_else(Task::none)
};
#[cfg(target_arch = "wasm32")]
let interaction_task = Task::none();
if let Some(opening) = &self.opening {
opening
.state
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
let pending_open_task = if let Some(report) = recovery_report {
self.recovery_report = Some(report);
self.active_modal = Some(crate::app::ModalKind::Recovery);
Task::none()
} else {
self.drain_pending_open()
};
Task::batch([thumbs_task, pending_open_task, interaction_task])
}
pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf,
) -> Task<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),)
}
fn sync_view_state_for_save(&mut self, i: usize) {
self.sync_vport_display(i);
if self.tabs[i].active_block_edit.is_none() {
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen =
self.tabs[i].scene.camera_generation;
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn prepare_native_save(&mut self, i: usize) {
self.sync_view_state_for_save(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.tabs[i].scene.document.header.user_real1 =
self.tabs[i].scene.annotation_scale as f64;
self.sync_solid_models_for_save(i);
}
#[cfg(not(target_arch = "wasm32"))]
fn native_save_verification(
path: &std::path::Path,
mut lease: Option<&mut crate::io::edit_lock::EditLease>,
expected: Option<crate::io::edit_lock::FileFingerprint>,
) -> Result<
(
Option<crate::io::edit_lock::FileFingerprint>,
Option<std::fs::File>,
),
crate::io::SaveFailure,
> {
let expected = match expected {
Some(expected) => Some(expected),
None => {
let captured = match lease.as_deref_mut() {
Some(lease) => match lease.fingerprint() {
Ok(Some(fingerprint)) => Ok(fingerprint),
Ok(None) => crate::io::edit_lock::FileFingerprint::capture(path),
Err(error) => Err(error),
},
None => crate::io::edit_lock::FileFingerprint::capture(path),
};
match captured {
Ok(fingerprint) => Some(fingerprint),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(crate::io::SaveFailure::other(format!(
"could not verify {} before saving: {error}",
path.display()
)));
}
}
}
};
let reader = if expected.is_some() {
match lease.as_deref() {
Some(lease) => lease.reader().map_err(|error| {
crate::io::SaveFailure::other(format!(
"could not verify {} before saving: {error}",
path.display()
))
})?,
None => None,
}
} else {
None
};
Ok((expected, reader))
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task<Message> {
let Some(pending) = self.pending_web_thumbnail_save.take() else {
return Task::none();
};
Task::done(Message::WebSaveScreenshot {
tab_id: pending.tab_id,
filename: pending.filename,
ext: pending.ext,
version: pending.version,
bounds: Some(pending.bounds),
screenshot: crate::sys::capture_canvas(),
})
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task<Message> {
let Some(pending) = self.pending_native_thumbnail_save.take() else {
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == pending.tab_id) else {
self.thumbnail_capture_clean = false;
return Task::none();
};
self.queue_native_save(
i,
pending.path,
pending.version,
pending.purpose,
pending.continuation,
pending.set_current_path,
pending.check_external_change,
)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_screenshot(
&mut self,
tab_id: u64,
filename: String,
ext: String,
version: acadrust::DxfVersion,
bounds: Option<iced::Rectangle>,
screenshot: Option<iced::window::Screenshot>,
) -> Task<Message> {
self.thumbnail_capture_clean = false;
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
return Task::none();
};
let preview = screenshot.as_ref().and_then(|screenshot| {
bounds.and_then(|bounds| {
crate::io::thumbnail::from_screenshot(
screenshot,
bounds,
version >= acadrust::DxfVersion::AC1027,
)
})
});
if let Some(preview) = preview {
self.tabs[i].scene.document.preview = Some(preview);
}
let mut recent_task = Task::none();
let saved = match crate::io::save_to_bytes(
&self.tabs[i].scene.document,
&ext,
version,
) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
self.tabs[i].dirty = false;
self.tabs[i].recovery_save_as_required = false;
recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
true
}
Err(error) => {
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
false
}
};
if self.save_dialog_for_unsaved {
if saved {
if let Some(crate::app::PendingClose::Tab(index)) = self.pending_close.take() {
let continuation = self.update(Message::TabClose(index));
let rest = self.continue_tab_close_queue();
return Task::batch([recent_task, continuation, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([recent_task, retry]);
}
}
recent_task
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn queue_native_save(
&mut self,
i: usize,
path: std::path::PathBuf,
version: acadrust::DxfVersion,
purpose: crate::app::SavePurpose,
continuation: crate::app::SaveContinuation,
set_current_path: bool,
check_external_change: bool,
) -> Task<Message> {
let tab_id = self.tabs[i].id;
if self
.pending_native_thumbnail_save
.as_ref()
.is_some_and(|pending| pending.tab_id == tab_id)
{
if purpose != crate::app::SavePurpose::Autosave {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
}
return Task::none();
}
let capture_ready =
self.thumbnail_capture_clean && self.pending_native_thumbnail_save.is_none();
if self.active_save_jobs.contains_key(&tab_id) {
if purpose != crate::app::SavePurpose::Autosave {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
}
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::none();
}
let destination_is_current = self.tabs[i]
.current_path
.as_deref()
.is_some_and(|current| native_paths_match(current, &path));
if purpose != crate::app::SavePurpose::Autosave
&& self.tabs[i].recovery_save_as_required
&& destination_is_current
{
self.command_line.push_error_once(
crate::tr!("recovery", "save-new-file-required").as_ref(),
);
self.restore_failed_save_continuation(continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved =
continuation != crate::app::SaveContinuation::None;
if capture_ready {
self.thumbnail_capture_clean = false;
}
return self.open_save_dialog_window(i);
}
if purpose != crate::app::SavePurpose::Autosave
&& !set_current_path
&& self.tabs[i].edit_lock_conflict
{
let error = "Drawing edit lock is held by another editor.".to_string();
self.command_line.push_error_once(crate::tf!(
"Unable to save \"{}\": {error}",
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
).as_ref());
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
error,
});
self.restore_failed_save_continuation(continuation, i);
self.active_modal = Some(crate::app::ModalKind::FileInUse);
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::none();
}
if purpose != crate::app::SavePurpose::Autosave
&& i == self.active_tab
&& !self.thumbnail_capture_clean
&& self.main_window.is_some()
&& crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
)
.is_some()
{
self.pending_native_thumbnail_save = Some(
crate::app::PendingNativeThumbnailSave {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
check_external_change,
},
);
self.thumbnail_capture_clean = true;
return Task::none();
}
if set_current_path && !destination_is_current {
match crate::io::edit_lock::EditLease::acquire(&path) {
Ok(lease) => {
self.pending_save_leases.insert(tab_id, lease);
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
self.command_line.push_error_once(crate::tf!(
"Unable to save \"{}\": {error}",
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
).as_ref());
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
error,
});
self.restore_failed_save_continuation(continuation, i);
self.active_modal = Some(crate::app::ModalKind::FileInUse);
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::none();
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.command_line.push_info(crate::tf!(
"{error}. External-change checks remain active."
).as_ref());
}
}
}
let epoch = self.tabs[i].scene.geometry_epoch;
let revision = self.tabs[i].edit_revision;
let camera_generation = self.tabs[i].scene.camera_generation;
let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab)
.then_some(version >= acadrust::DxfVersion::AC1027);
let capture_bounds = thumbnail.and_then(|_| {
crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
)
});
let clone_started = iced::time::Instant::now();
let snapshot = self.tabs[i].scene.document.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-snapshot {:.1}ms entities={} objects={} purpose={purpose:?}",
clone_ms,
snapshot.entities().count(),
snapshot.objects.len(),
);
}
self.save_job_serial = self.save_job_serial.wrapping_add(1);
let job_id = self.save_job_serial;
self.active_save_jobs.insert(tab_id, job_id);
let previous_autosave =
(purpose != crate::app::SavePurpose::Autosave).then(|| self.autosave_target(i));
let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save;
let verification = if check_external_change
&& purpose != crate::app::SavePurpose::Autosave
{
let expected = if set_current_path {
None
} else {
self.tabs[i].disk_fingerprint.clone()
};
if self.pending_save_leases.contains_key(&tab_id) {
Self::native_save_verification(
&path,
self.pending_save_leases.get_mut(&tab_id),
expected,
)
} else if destination_is_current {
Self::native_save_verification(
&path,
self.tabs[i].edit_lease.as_mut(),
expected,
)
} else {
Self::native_save_verification(&path, None, expected)
}
} else {
Ok((None, None))
};
let (expected_fingerprint, verify_reader) = match verification {
Ok(verification) => verification,
Err(error) => {
if capture_ready {
self.thumbnail_capture_clean = false;
}
return Task::perform(
async move {
crate::app::SaveOutcome {
job_id,
tab_id,
epoch,
revision,
camera_generation,
path,
version,
previous_autosave,
set_current_path,
purpose,
continuation,
refreshed_preview: None,
result: Err(error),
}
},
Message::SaveFinished,
);
}
};
let worker_path = path.clone();
let capture_window = thumbnail.and(capture_bounds).and(self.main_window);
let mut work = Some((
snapshot,
thumbnail,
capture_bounds,
worker_path,
expected_fingerprint,
verify_reader,
path,
previous_autosave,
));
let mut run_save = move |screenshot: Option<iced::window::Screenshot>| {
let (
mut snapshot,
thumbnail,
capture_bounds,
worker_path,
expected_fingerprint,
verify_reader,
path,
previous_autosave,
) = work.take().expect("save capture produced more than one result");
Task::perform(
async move {
let (result, refreshed_preview) = std::thread::spawn(move || {
let mut refreshed_preview = None;
if let Some(png) = thumbnail {
let started = iced::time::Instant::now();
if let Some(preview) = screenshot.as_ref().and_then(|screenshot| {
capture_bounds.and_then(|bounds| {
crate::io::thumbnail::from_screenshot(screenshot, bounds, png)
})
}) {
snapshot.preview = Some(preview);
refreshed_preview = Some(snapshot.preview.clone());
}
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-thumbnail {:.1}ms",
started.elapsed().as_secs_f64() * 1000.0,
);
}
}
let result = crate::io::save_owned_as_version_atomic(
snapshot,
&worker_path,
version,
backup,
expected_fingerprint,
verify_reader,
);
(result, refreshed_preview)
})
.join()
.unwrap_or_else(|_| {
(
Err(crate::io::SaveFailure::other("save worker panicked")),
None,
)
});
crate::app::SaveOutcome {
job_id,
tab_id,
epoch,
revision,
camera_generation,
path,
version,
previous_autosave,
set_current_path,
purpose,
continuation,
refreshed_preview,
result,
}
},
Message::SaveFinished,
)
};
match capture_window {
Some(window) => iced::window::screenshot(window).map(Some).then(move |screenshot| {
Task::batch([
Task::done(Message::ThumbnailCaptureFinished),
run_save(screenshot),
])
}),
None => {
self.thumbnail_capture_clean = false;
run_save(None)
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_finished(
&mut self,
outcome: crate::app::SaveOutcome,
) -> Task<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 destination_lease = self.pending_save_leases.remove(&outcome.tab_id);
let Some(i) = self.tabs.iter().position(|tab| tab.id == outcome.tab_id) else {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
};
if !latest {
if outcome.purpose == crate::app::SavePurpose::Autosave {
let _ = std::fs::remove_file(&outcome.path);
}
return Task::none();
}
if let Err(error) = &outcome.result {
if error.externally_modified
&& outcome.purpose != crate::app::SavePurpose::Autosave
{
let file_name = outcome
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| outcome.path.display().to_string());
self.command_line.push_error_once(crate::tf!(
"Save stopped: \"{file_name}\" changed outside Open CAD Studio."
).as_ref());
self.pending_external_change = Some(crate::app::PendingExternalChange {
tab_id: outcome.tab_id,
path: outcome.path.clone(),
version: outcome.version,
purpose: outcome.purpose,
continuation: outcome.continuation,
set_current_path: outcome.set_current_path,
});
self.restore_failed_save_continuation(outcome.continuation, i);
self.active_modal = Some(crate::app::ModalKind::ExternalChange);
return Task::none();
}
if error.file_in_use && outcome.purpose != crate::app::SavePurpose::Autosave {
let file_name = outcome
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| outcome.path.display().to_string());
self.command_line.push_error_once(crate::tf!(
"Unable to save \"{file_name}\": file is in use by another application."
).as_ref());
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id: outcome.tab_id,
path: outcome.path.clone(),
version: outcome.version,
purpose: outcome.purpose,
continuation: outcome.continuation,
set_current_path: outcome.set_current_path,
error: error.to_string(),
});
match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
}
crate::app::SaveContinuation::None => {}
}
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
return match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
self.open_unsaved_dialog_window()
}
crate::app::SaveContinuation::None => Task::none(),
};
}
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == outcome.epoch
&& self.tabs[i].edit_revision == outcome.revision
&& self.tabs[i].scene.camera_generation == outcome.camera_generation;
if snapshot_is_current && outcome.purpose != crate::app::SavePurpose::Autosave {
if let Some(preview) = outcome.refreshed_preview {
self.tabs[i].scene.document.preview = preview;
}
}
let mut tasks = Vec::new();
match outcome.purpose {
crate::app::SavePurpose::Autosave => {
self.command_line.push_output(crate::t!("Autosaved 1 drawing").as_ref());
}
crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => {
let path_changed = outcome.set_current_path
&& self.tabs[i]
.current_path
.as_deref()
.is_none_or(|current| {
!native_paths_match(current, &outcome.path)
});
self.command_line
.push_output(crate::tf!("Saved: {}", outcome.path.display()).as_ref());
if let Some(previous) = outcome.previous_autosave {
if previous != outcome.path {
let _ = std::fs::remove_file(previous);
}
}
if outcome.set_current_path {
self.tabs[i].current_path = Some(outcome.path.clone());
self.tabs[i].scene.document.version = outcome.version;
if outcome.purpose == crate::app::SavePurpose::SaveAs {
self.tabs[i].recovery_save_as_required = false;
}
}
tasks.push(self.push_recent(outcome.path.clone()));
self.refresh_native_edit_guard_after_save(
i,
&outcome.path,
path_changed,
destination_lease,
);
if snapshot_is_current {
self.tabs[i].dirty = false;
}
}
}
match outcome.continuation {
crate::app::SaveContinuation::None => {}
crate::app::SaveContinuation::CloseTab if snapshot_is_current => {
self.pending_close = None;
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.update(Message::TabClose(i)));
tasks.push(self.continue_tab_close_queue());
}
crate::app::SaveContinuation::Quit if snapshot_is_current => {
self.pending_close = None;
if self.tabs.iter().any(|tab| tab.dirty) {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
} else {
tasks.push(self.close_unsaved_dialog_window());
tasks.push(self.exit_app());
}
}
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
tasks.push(self.open_unsaved_dialog_window());
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
tasks.push(self.open_unsaved_dialog_window());
}
}
Task::batch(tasks)
}
#[cfg(not(target_arch = "wasm32"))]
fn restore_failed_save_continuation(
&mut self,
continuation: crate::app::SaveContinuation,
tab_idx: usize,
) {
self.pending_close = match continuation {
crate::app::SaveContinuation::None => None,
crate::app::SaveContinuation::CloseTab => {
Some(crate::app::PendingClose::Tab(tab_idx))
}
crate::app::SaveContinuation::Quit => Some(crate::app::PendingClose::Quit),
};
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_file_in_use_retry(&mut self) -> Task<Message> {
let Some(mut failure) = self.pending_save_failure.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == failure.tab_id) else {
self.close_active_modal();
return Task::none();
};
if self.tabs[i].edit_lock_conflict {
if let Err(error) = self.retry_native_edit_guard(i, &failure.path) {
let continuation = failure.continuation;
failure.error = error.clone();
self.pending_save_failure = Some(failure);
self.restore_failed_save_continuation(continuation, i);
self.command_line
.push_error_once(crate::tf!("Unable to acquire edit lock: {error}").as_ref());
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
}
self.close_active_modal();
self.restore_failed_save_continuation(failure.continuation, i);
self.active_tab = i;
self.prepare_native_save(i);
self.queue_native_save(
i,
failure.path,
failure.version,
failure.purpose,
failure.continuation,
failure.set_current_path,
true,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_file_in_use_save_as(&mut self) -> Task<Message> {
let Some(failure) = self.pending_save_failure.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == failure.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(failure.continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved =
failure.continuation != crate::app::SaveContinuation::None;
self.open_save_dialog_window(i)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_overwrite(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(conflict.continuation, i);
self.active_tab = i;
self.prepare_native_save(i);
self.queue_native_save(
i,
conflict.path,
conflict.version,
conflict.purpose,
conflict.continuation,
conflict.set_current_path,
false,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_save_as(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(conflict.continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved =
conflict.continuation != crate::app::SaveContinuation::None;
self.open_save_dialog_window(i)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_reload(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
let metadata = match std::fs::metadata(&conflict.path) {
Ok(metadata) => metadata,
Err(error) => {
self.close_active_modal();
self.command_line
.push_error(crate::tf!("Reload failed: {error}").as_ref());
return Task::none();
}
};
self.close_active_modal();
self.pending_close = None;
self.tab_counter += 1;
self.tabs[i] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = i;
self.apply_display_defaults(i);
self.update(Message::OpenPathPicked(Some((
conflict.path,
metadata.len(),
))))
}
pub(super) fn on_save_file(&mut self) -> Task<Message> {
if self.read_only {
self.command_line
.push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref());
return Task::none();
}
let i = self.active_tab;
// Web serializes immediately below. Native preparation happens
// once after the destination/version is known.
#[cfg(target_arch = "wasm32")]
{
self.sync_view_state_for_save(i);
self.stamp_header_sysvars(i);
}
// Native: save straight to the known path. Web has no path
// (downloads instead), so always go through the Save dialog.
#[cfg(not(target_arch = "wasm32"))]
if !self.tabs[i].recovery_save_as_required {
if let Some(path) = self.tabs[i].current_path.clone() {
// A direct Save preserves the document's current version.
let ver = self.tabs[i].scene.document.version;
self.prepare_native_save(i);
return self.queue_native_save(
i,
path,
ver,
crate::app::SavePurpose::Manual,
crate::app::SaveContinuation::None,
false,
true,
);
}
}
self.save_dialog_for_unsaved = false;
self.save_with_default_format(i)
}
/// Save without the version picker: use the configured default and go straight to
/// the native destination dialog (native) or the browser download (web).
/// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the
/// save-before-close flow — the version picker is reserved for Save As.
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<Message> {
self.active_tab = tab_idx;
self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required {
let document = &self.tabs[tab_idx].scene.document;
let is_dxf = crate::io::source_is_dxf(
self.tabs[tab_idx].current_path.as_deref(),
document,
);
let version = if is_dxf {
document.version
} else {
document.dwg_source_version.unwrap_or(document.version)
};
crate::io::format_for_version(version, is_dxf)
} else {
self.default_save_format.clone()
};
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
self.save_dialog_filename = self.tabs[tab_idx]
.current_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()));
if self.tabs[tab_idx].recovery_save_as_required {
let path = std::path::Path::new(&self.save_dialog_filename);
let stem = path
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
self.save_dialog_filename = format!("{stem}_recovered.{ext}");
}
self.aec_drop_acknowledged = false;
self.on_save_dialog_confirm()
}
pub(super) fn on_save_dialog_confirm(&mut self) -> Task<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(crate::t!("Save Drawing As").as_ref())
.set_file_name(default_name)
.add_filter(filter_label, &[filter_ext]);
if let Some(dir) = seed_dir {
dlg = dlg.set_directory(dir);
}
dlg.save_file().await.map(|h| crate::sys::handle_path(&h))
},
Message::SaveDialogPathPicked,
);
Task::batch([close, pick])
}
// Web: no filesystem — serialize and hand the browser a download.
#[cfg(target_arch = "wasm32")]
{
let close = self.close_save_dialog_window();
self.sync_view_state_for_save(i);
sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.sync_solid_models_for_save(i);
let tab_id = self.tabs[i].id;
let bounds = crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
);
let Some(bounds) = bounds else {
return Task::batch([
close,
Task::done(Message::WebSaveScreenshot {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds: None,
screenshot: None,
}),
]);
};
self.pending_web_thumbnail_save = Some(
crate::app::PendingWebThumbnailSave {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds,
},
);
self.thumbnail_capture_clean = true;
close
}
}
/// Native: a destination path came back from the OS save dialog — write the
/// file there in the chosen version. `None` means the user cancelled.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_dialog_path_picked(
&mut self,
picked: Option<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,
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 source type and version, then save.
pub(super) fn on_aec_drop_same_version(&mut self) -> Task<Message> {
let tab = &self.tabs[self.active_tab];
let document = &tab.scene.document;
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), document);
let src = if is_dxf {
document.version
} else {
document.dwg_source_version.unwrap_or(document.version)
};
self.save_dialog_format = crate::io::format_for_version(src, is_dxf);
// Strip the old extension so the confirm path appends the source type.
let stem = std::path::Path::new(&self.save_dialog_filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.save_dialog_filename.clone());
self.save_dialog_filename = stem;
self.aec_drop_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// Where the autosave recovery copy for tab `i` lives.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn autosave_target(&self, i: usize) -> std::path::PathBuf {
match &self.tabs[i].current_path {
Some(p) => {
let name = p
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".to_string());
p.with_file_name(format!("{name}.ocs-autosave.sv$"))
}
None => {
let safe: String = self.tabs[i]
.tab_display_name()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
std::env::temp_dir().join(format!(
"OpenCADStudio_{safe}_{}.sv$",
self.tabs[i].id
))
}
}
}
/// Periodic autosave (SAVETIME): write a `.sv$` recovery copy for every
/// dirty tab — beside the file if it's saved, else under the temp dir — at
/// the document's own DWG version. Best-effort and non-destructive: it never
/// touches the original file or the dirty flag.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_autosave(&mut self) -> Task<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;
}
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let target = self.autosave_target(i);
tasks.push(self.queue_native_save(
i,
target,
version,
crate::app::SavePurpose::Autosave,
crate::app::SaveContinuation::None,
false,
false,
));
}
Task::batch(tasks)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_autosave(&mut self) -> Task<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.
/// No-op on the Model tab (which has no paper layout). Marks the tab dirty
/// and re-tessellates the sheet. Called by the Plot dialog's Set current action.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_plot_page_settings(
&mut self,
w: f64,
h: f64,
plot_area: &str,
center: bool,
offset_x: f64,
offset_y: f64,
rotation: i16,
) {
let i = self.active_tab;
let dialog = self.plot_dialog.clone();
let layout_name = self.tabs[i].scene.current_layout.clone();
let plot_window = self.plot_window;
if layout_name != "Model" {
let w: f64 = w.max(1.0);
let h: f64 = h.max(1.0);
use acadrust::objects::{
PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode,
ShadePlotResolutionLevel,
};
let mut ps = self
.plot_setup_template
.clone()
.or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name))
.unwrap_or_else(|| PlotSettings::new(""));
ps.paper_width = w;
ps.paper_height = h;
ps.paper_size = dialog.paper.clone();
ps.plot_type = match plot_area {
"Window" => PlotType::Window,
"Display" => PlotType::LastScreenDisplay,
"Extents" => PlotType::Extents,
"Limits" => PlotType::Limits,
area if area.starts_with("View: ") => PlotType::View,
_ => PlotType::Layout,
};
ps.plot_view_name = plot_area
.strip_prefix("View: ")
.unwrap_or("")
.to_string();
if plot_area == "Window" {
if let Some((x0, y0, x1, y1)) = plot_window {
ps.set_plot_window(x0, y0, x1, y1);
}
}
ps.flags.plot_centered = center && plot_area != "Layout";
ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x };
ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y };
ps.rotation = match rotation {
90 => PlotRotation::Degrees90,
180 => PlotRotation::Degrees180,
270 => PlotRotation::Degrees270,
_ => PlotRotation::None,
};
if dialog.fit_to_paper && plot_area != "Layout" {
ps.set_scale_to_fit();
} else if plot_area == "Layout" {
ps.set_standard_scale(ScaledType::OneToOne);
ps.standard_scale_factor = 1.0;
} else {
let factor = plot_dialog_scale_factor(&dialog);
ps.scale_type = ScaledType::CustomScale;
ps.scale_numerator = factor;
ps.scale_denominator = 1.0;
ps.standard_scale_factor = factor;
ps.flags.use_standard_scale = false;
}
ps.printer_name = if dialog.to_file {
crate::ui::window::plot::OUT_PDF.into()
} else {
dialog.printer.clone().unwrap_or_default()
};
ps.current_style_sheet = dialog.style_name.clone();
ps.flags.scale_lineweights = dialog.scale_lw;
ps.flags.print_lineweights = dialog.lineweights;
ps.flags.plot_plot_styles = dialog.apply_plot_styles && !dialog.style_name.is_empty();
ps.flags.show_plot_styles = dialog.show_plot_styles && !dialog.style_name.is_empty();
ps.flags.draw_viewports_first = dialog.paperspace_last;
ps.flags.plot_hidden = dialog.shade == "Hidden Line";
ps.shade_plot_mode = match dialog.shade.as_str() {
"2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
"Hidden Line" => ShadePlotMode::Hidden,
"As displayed" => ShadePlotMode::AsDisplayed,
_ => ShadePlotMode::Rendered,
};
ps.shade_plot_resolution = match dialog.quality.as_str() {
"Low" => ShadePlotResolutionLevel::Draft,
"High" => ShadePlotResolutionLevel::Presentation,
_ => ShadePlotResolutionLevel::Normal,
};
ps.shade_plot_dpi = 300;
self.tabs[i]
.scene
.set_layout_plot_settings(&layout_name, &ps);
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(layout) = obj {
if layout.name == layout_name {
layout.min_limits = (0.0, 0.0);
layout.max_limits = (w, h);
layout.min_extents = (0.0, 0.0, 0.0);
layout.max_extents = (w, h, 0.0);
break;
}
}
}
self.tabs[i].dirty = true;
self.tabs[i].scene.bump_geometry_no_blocks();
self.command_line.push_info(crate::tf!(
"Page setup: {w:.1}×{h:.1} mm area={plot_area} \
center={center} rot={rotation}°"
).as_ref());
}
}
pub(in crate::app) fn on_plot_export_path_some(
&mut self,
path: std::path::PathBuf,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model" {
self.plot_dialog.paper_space = true;
self.plot_dialog.scales = self.tabs[i]
.scene
.scale_list()
.into_iter()
.map(|(name, _, factor)| (name, factor))
.collect();
if let Some(settings) = self.tabs[i].scene.effective_plot_settings() {
self.load_plotsettings_into_dialog(&settings);
}
}
let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) =
self.direct_plot_params()
else {
self.command_line
.push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref());
return Task::none();
};
let plot_style = self.dialog_plot_style(&self.plot_dialog);
let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits);
let worker_path = path.clone();
let work = move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
page_w,
page_h,
ox,
oy,
rotation,
scale,
clip,
&worker_path,
plot_style.as_ref(),
render_options,
)
.map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned())
.map_err(|e| crate::tf!("Export failed: {e}").into_owned())
};
self.run_plot_work(self.plot_dialog.background, false, work)
}
/// Export the pending Extents/Window/Display area using the same clipped
/// render path in model space and paper space.
pub(super) fn on_plot_window_export_path_some(
&mut self,
path: std::path::PathBuf,
) -> Task<Message> {
let job = match self.plot_dialog.area.as_str() {
"Display" => self.display_plot_job(),
"Extents" => self.extents_plot_job(),
_ => self.window_plot_job(),
};
let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) =
job
else {
self.command_line
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
return Task::none();
};
let plot_style = self.dialog_plot_style(&self.plot_dialog);
let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits);
let worker_path = path.clone();
self.close_active_modal();
let work = move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
page_w,
page_h,
ox,
oy,
rotation,
scale,
clip,
&worker_path,
plot_style.as_ref(),
render_options,
)
.map(|_| {crate::tf!(
"Plotted window to {}",
worker_path
.file_name().unwrap_or_default().to_string_lossy()
).into_owned()
}).map_err(|e| crate::tf!("Plot failed: {e}").into_owned())
};
self.run_plot_work(self.plot_dialog.background, false, work)
}
pub(super) fn report_plot_result(
&mut self,
result: Result<String, String>,
reopen_plot: bool,
) {
match result {
Ok(message) => self.command_line.push_info(&message),
Err(error) => self.command_line.push_error(&error),
}
if reopen_plot {
self.active_modal = Some(crate::app::ModalKind::Plot);
}
}
pub(super) fn on_print_all_open(&mut self) -> Task<Message> {
self.print_all_layouts = self.tabs[self.active_tab]
.scene
.layout_names()
.into_iter()
.filter(|name| name != "Model")
.map(|name| (name, true))
.collect();
self.print_all_settings_override = false;
self.active_modal = Some(crate::app::ModalKind::PrintAll);
self.reset_modal_geometry();
Task::none()
}
pub(super) fn on_print_all_options(&mut self) -> Task<Message> {
let previous = self.plot_dialog.clone();
let previous_style = self.active_plot_style.clone();
let previous_window = self.plot_window;
let previous_setup = self.plot_setup_template.clone();
let task = self.on_plot_dialog_open();
self.print_all_options_prev = Some(previous);
self.print_all_plot_style_prev = Some(previous_style);
self.print_all_plot_window_prev = Some(previous_window);
self.print_all_plot_setup_prev = Some(previous_setup);
self.print_all_options = true;
self.plot_dialog.paper_space = true;
self.plot_dialog.area = "Layout".into();
task
}
fn print_all_pages(&mut self) -> Result<Vec<crate::io::pdf_export::PdfPageInput>, String> {
let available = self.tabs[self.active_tab].scene.layout_names();
let selected: Vec<String> = self
.print_all_layouts
.iter()
.filter(|(name, checked)| *checked && available.contains(name))
.map(|(name, _)| name.clone())
.collect();
if selected.is_empty() {
return Err(crate::t!("Select at least one layout.").into_owned());
}
let i = self.active_tab;
let original_layout = self.tabs[i].scene.current_layout.clone();
let original_viewport = self.tabs[i].scene.active_viewport;
let original_psltscale = self.tabs[i]
.scene
.document
.header
.paper_space_linetype_scaling;
let original_plimcheck = self.tabs[i]
.scene
.document
.header
.paper_space_limit_check;
let original_dialog = self.plot_dialog.clone();
let original_style = self.active_plot_style.clone();
let original_window = self.plot_window;
let original_setup = self.plot_setup_template.clone();
let original_camera = self.tabs[i].scene.camera.borrow().clone();
let original_camera_generation = self.tabs[i].scene.camera_generation;
let override_dialog = self.print_all_settings_override.then(|| original_dialog.clone());
let override_style = self.print_all_settings_override.then(|| original_style.clone());
let result = (|| {
let mut pages = Vec::with_capacity(selected.len());
for name in selected {
let page_setup = self.tabs[i]
.scene
.plot_settings_for(&name)
.ok_or_else(|| crate::tf!("Layout '{name}' has no page setup.").into_owned())?;
{
let scene = &mut self.tabs[i].scene;
scene.current_layout = name.clone();
scene.active_viewport = None;
scene.load_current_layout_state();
}
if let Some(dialog) = &override_dialog {
self.plot_dialog = dialog.clone();
self.active_plot_style = override_style.clone().flatten();
self.plot_window = original_window;
} else {
self.plot_dialog.paper_space = true;
self.plot_window = None;
self.load_plotsettings_into_dialog(&page_setup);
}
let dialog = self.plot_dialog.clone();
if dialog.area == "Display" {
self.tabs[i].scene.restore_saved_camera();
}
if dialog.style_missing && dialog.apply_plot_styles {
return Err(crate::tf!(
"Layout '{name}' plot style table '{}' is not loaded.",
dialog.style_name
).into_owned());
}
let plot_style = self.dialog_plot_style(&dialog);
let params = match dialog.area.as_str() {
"Display" => self.display_plot_job(),
"Extents" => self.extents_plot_job(),
"Limits" => self.limits_plot_job(),
"Window" => self.window_plot_job(),
area if area.starts_with("View: ") => {
self.named_view_plot_job(area.trim_start_matches("View: "))
}
_ => None,
};
let (
wires,
hatches,
wipeouts,
group_splits,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
) = if dialog.area == "Layout" {
self.layout_plot_params_for("Layout")
} else {
let (
wires,
hatches,
wipeouts,
group_splits,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?;
(
std::sync::Arc::new(wires),
hatches,
wipeouts,
group_splits,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
)
};
pages.push(crate::io::pdf_export::PdfPageInput {
wires,
hatches,
wipeouts,
paper_w,
paper_h,
offset_x,
offset_y,
rotation_deg,
scale,
clip,
options: Self::pdf_plot_options(&dialog, group_splits),
plot_style,
});
}
Ok(pages)
})();
self.tabs[i].scene.current_layout = original_layout;
self.tabs[i].scene.active_viewport = original_viewport;
self.tabs[i]
.scene
.document
.header
.paper_space_linetype_scaling = original_psltscale;
self.tabs[i]
.scene
.document
.header
.paper_space_limit_check = original_plimcheck;
self.plot_dialog = original_dialog;
self.active_plot_style = original_style;
self.plot_window = original_window;
self.plot_setup_template = original_setup;
*self.tabs[i].scene.camera.borrow_mut() = original_camera;
self.tabs[i].scene.camera_generation = original_camera_generation;
result
}
pub(super) fn on_print_all_pdf_path_some(
&mut self,
path: std::path::PathBuf,
) -> Task<Message> {
let dialog = self.plot_dialog.clone();
if self.print_all_settings_override
&& dialog.style_missing
&& dialog.apply_plot_styles
{
self.command_line.push_error(crate::tf!(
"Plot style table '{}' is not loaded.",
dialog.style_name
).as_ref());
return Task::none();
}
let pages = match self.print_all_pages() {
Ok(pages) => pages,
Err(error) => {
self.command_line.push_error(&error);
return Task::none();
}
};
let worker_path = path.clone();
self.save_config();
self.close_active_modal();
let work = move || {
crate::io::pdf_export::export_pdf_pages(
&pages,
&worker_path,
None,
)
.map(|_| crate::tf!("Exported {} layouts to {}", pages.len(), worker_path.display()).into_owned())
.map_err(|error| crate::tf!("Export failed: {error}").into_owned())
};
self.run_print_all_work(dialog.background, work)
}
pub(super) fn on_print_all_print(&mut self) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
{
self.command_line.push_error(
crate::t!("Printing is not available in the web version.").as_ref(),
);
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
{
let dialog = self.plot_dialog.clone();
if self.print_all_settings_override
&& dialog.style_missing
&& dialog.apply_plot_styles
{
self.command_line.push_error(crate::tf!(
"Plot style table '{}' is not loaded.",
dialog.style_name
).as_ref());
return Task::none();
}
let pages = match self.print_all_pages() {
Ok(pages) => pages,
Err(error) => {
self.command_line.push_error(&error);
return Task::none();
}
};
let options = self.plot_print_options(&dialog, Default::default());
let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all");
self.save_config();
self.close_active_modal();
self.command_line.push_info(
crate::t!("Sending selected layouts to the system printer…").as_ref(),
);
let work = move || {
crate::io::pdf_export::export_pdf_pages(
&pages,
&temp_path,
None,
)
.and_then(|_| {
crate::io::print_to_printer::print_existing_pdf(&temp_path, &options)
})
.map(|printer| crate::tf!("Sent {} layouts to printer: {printer}", pages.len()).into_owned())
.map_err(|error| crate::tf!("Print failed: {error}").into_owned())
};
self.run_print_all_work(true, work)
}
}
fn run_print_all_work<F>(&mut self, background: bool, work: F) -> Task<Message>
where
F: FnOnce() -> Result<String, String> + Send + 'static,
{
if background {
background_task(work, Message::PrintAllFinished)
} else {
Task::done(Message::PrintAllFinished(work()))
}
}
fn run_plot_work<F>(
&mut self,
background: bool,
reopen_plot: bool,
work: F,
) -> Task<Message>
where
F: FnOnce() -> Result<String, String> + Send + 'static,
{
if background {
background_task(work, move |result| {
Message::BackgroundIoFinished(result, reopen_plot)
})
} else {
let result = work();
self.report_plot_result(result, reopen_plot);
Task::none()
}
}
/// Build the render inputs and page geometry for a full-layout plot: wires
/// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size,
/// draw-origin offset, rotation, unit scale, and optional clip. Shared by
/// PDF export, preview, and printer output so all three render identically.
pub(super) fn layout_plot_params(&self) -> LayoutPlotParams {
self.layout_plot_params_for(&self.plot_dialog.area)
}
fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let paper_space = scene.current_layout != "Model";
let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog);
let (source_wires, hatches, wipeouts, mut group_splits) =
plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
// The printable-area rectangle is an on-screen guide, not drawing
// content. It used to leak into every paper-space PDF/preview/print.
let wires = if paper_space {
group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())]
.iter()
.filter(|wire| wire.name != "paper_printable_area")
.count();
std::sync::Arc::new(
source_wires
.iter()
.filter(|wire| wire.name != "paper_printable_area")
.cloned()
.collect(),
)
} else {
source_wires
};
if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() {
// Paper geometry is stored in layout units; PDF pages use mm.
// Scale both geometry and offsets by the same unit conversion.
// The old path converted only page width/height, so inch/scaled
// layouts placed tiny or 25.4×-shifted content on the page.
let units_per_mm = scene.paper_space_unit_factor().max(1e-9);
let mm_per_unit = 1.0 / units_per_mm;
let paper_w = ((x1 - x0) * mm_per_unit).max(1.0);
let paper_h = ((y1 - y0) * mm_per_unit).max(1.0);
// Positioning choices are transient until explicitly saved. Layout
// area keeps the sheet's current physical bounds; the other area
// modes use the dialog paper size through `area_plot_job`.
let dialog_rotation = if self.plot_dialog.upside_down { 180 } else { 0 };
let plot_extents = plot_area == "Extents";
let plot_layout = plot_area == "Layout";
// Page orientation is already represented by the sheet bounds.
// Only the explicit upside-down choice rotates layout content here.
let rotation = dialog_rotation;
let centered = self.plot_dialog.center;
let origin_x = self
.plot_dialog
.offset_x
.parse::<f64>()
.unwrap_or(0.0);
let origin_y = self
.plot_dialog
.offset_y
.parse::<f64>()
.unwrap_or(0.0);
let (scale, offset_x, offset_y) = if plot_extents {
let (min_x, min_y, max_x, max_y) =
plot_content_extents(&wires, &hatches, &wipeouts)
.unwrap_or((x0, y0, x1, y1));
let content_w = (max_x - min_x).max(1e-9);
let content_h = (max_y - min_y).max(1e-9);
let scale = if self.plot_dialog.fit_to_paper {
const MARGIN: f64 = 1.05;
((paper_w / MARGIN) / content_w)
.min((paper_h / MARGIN) / content_h)
.max(1e-9)
} else {
(plot_dialog_scale_factor(&self.plot_dialog) * mm_per_unit).max(1e-9)
};
let target_x = if centered {
(paper_w - content_w * scale) * 0.5
} else {
origin_x
};
let target_y = if centered {
(paper_h - content_h * scale) * 0.5
} else {
origin_y
};
(
scale,
target_x / scale - min_x,
target_y / scale - min_y,
)
} else if plot_layout {
// Map the complete paper-space sheet to the physical paper
// selected in the dialog. Layout bounds can be stored in mm,
// inches, or carry incomplete legacy metadata; deriving the
// scale from the visible sheet avoids applying a stale unit
// factor twice while preserving loaded layouts exactly when
// their metadata is valid.
let bounds_w = (x1 - x0).max(1e-9);
let bounds_h = (y1 - y0).max(1e-9);
let scale = (selected_sheet.0 / bounds_w)
.min(selected_sheet.1 / bounds_h)
.max(1e-9);
let target_x = (selected_sheet.0 - bounds_w * scale) * 0.5;
let target_y = (selected_sheet.1 - bounds_h * scale) * 0.5;
(
scale,
target_x / scale - x0,
target_y / scale - y0,
)
} else {
// Legacy direct callers still get dialog positioning. Normal
// Display/Window paths use `area_plot_job` instead.
let scale = mm_per_unit;
let target_x = if centered {
(paper_w - (x1 - x0) * scale) * 0.5
} else {
origin_x
};
let target_y = if centered {
(paper_h - (y1 - y0) * scale) * 0.5
} else {
origin_y
};
(scale, target_x / scale - x0, target_y / scale - y0)
};
let (base_page_w, base_page_h) = if plot_layout {
selected_sheet
} else {
(paper_w, paper_h)
};
let (page_w, page_h) = match rotation {
90 | 270 => (base_page_h, base_page_w),
_ => (base_page_w, base_page_h),
};
// Layout plots keep the full physical sheet as the PDF page, but
// ink is restricted to the device's printable rectangle. Extents
// has its own fitted area and must not inherit this paper clip.
let clip = if plot_layout {
scene.printable_area_limits().map(|((px0, py0), (px1, py1))| {
(
(px0 + offset_x) as f32,
(py0 + offset_y) as f32,
(px1 - px0) as f32,
(py1 - py0) as f32,
)
})
} else {
None
};
return (
wires,
hatches,
wipeouts,
group_splits,
page_w,
page_h,
offset_x,
offset_y,
rotation,
scale as f32,
clip,
);
}
// Model space keeps its established extents + 5% margin behaviour.
let (page_w, page_h, offset_x, offset_y) =
if let Some((min, max)) = scene.model_space_extents() {
let margin = 1.05_f64;
let width = ((max.x - min.x) as f64 * margin).max(1.0);
let height = ((max.y - min.y) as f64 * margin).max(1.0);
let pad_x = (width - (max.x - min.x) as f64) * 0.5;
let pad_y = (height - (max.y - min.y) as f64) * 0.5;
(
width,
height,
-(min.x as f64) + pad_x,
-(min.y as f64) + pad_y,
)
} else {
(297.0, 210.0, 0.0, 0.0)
};
(
wires,
hatches,
wipeouts,
group_splits,
page_w,
page_h,
offset_x,
offset_y,
0,
1.0,
None,
)
}
/// Build direct PDF/printer output using the physical layout sheet in
/// paper space and the selected ISO sheet/scale around Model extents.
fn direct_plot_params(&self) -> Option<LayoutPlotParams> {
if self.tabs[self.active_tab].scene.current_layout != "Model" {
return Some(self.layout_plot_params_for("Layout"));
}
let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) =
self.extents_plot_job()?;
if wires.is_empty() && hatches.is_empty() && wipeouts.is_empty() {
return None;
}
Some((
std::sync::Arc::new(wires),
hatches,
wipeouts,
group_splits,
page_w,
page_h,
ox,
oy,
rotation,
scale,
clip,
))
}
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) =
self.direct_plot_params()
else {
self.command_line
.push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref());
return Task::none();
};
let plot_style = self.dialog_plot_style(&self.plot_dialog);
let options = self.plot_print_options(&self.plot_dialog, group_splits);
self.command_line.push_info(crate::t!("Sending to system printer…").as_ref());
background_task(
move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires_with(
wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip,
plot_style, options,
))
},
Message::PrintResult,
)
}
/// QUICKPRINT / QP — use the current selection's bounding box as the plot
/// window and export a PDF (drawing folder + name + timestamp) with the
/// active page setup, no dialog. Model space only. (#325)
pub(crate) fn on_quick_print_handles(
&mut self,
handles: Vec<acadrust::Handle>,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model" {
self.command_line
.push_error(crate::t!("Quick print works in model space.").as_ref());
return Task::none();
}
let set: std::collections::HashSet<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(crate::t!("Selection has no printable geometry.").as_ref());
return Task::none();
}
if !(x1 > x0 && y1 > y0) {
self.command_line
.push_error(crate::t!("Selection has no printable area.").as_ref());
return Task::none();
}
// Small margin so the outermost strokes aren't clipped flush to the edge.
let mx = ((x1 - x0) * 0.02).max(0.0);
let my = ((y1 - y0) * 0.02).max(0.0);
self.plot_window = Some((
(x0 - mx) as f64,
(y0 - my) as f64,
(x1 + mx) as f64,
(y1 + my) as f64,
));
let path = self.quick_print_path();
self.on_plot_window_export_path_some(path)
}
/// Auto output path for quick print: the drawing's folder + name + a
/// timestamp, falling back to the temp dir / "drawing" when unsaved.
fn quick_print_path(&self) -> std::path::PathBuf {
let i = self.active_tab;
let cur = self.tabs[i].current_path.as_deref();
let dir = cur
.and_then(|p| p.parent())
.map(|d| d.to_path_buf())
.unwrap_or_else(std::env::temp_dir);
let stem = cur
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
dir.join(format!("{stem}_{ts}.pdf"))
}
/// Open the full Plot / Print dialog, seeding its state from the active
/// layout's plot settings and the printers found on the system.
pub(super) fn on_plot_dialog_open(&mut self) -> Task<Message> {
use crate::io::paper_sizes::Orientation;
let previous = self.plot_dialog.clone();
let scales: Vec<(String, f64)> = self.tabs[self.active_tab]
.scene
.scale_list()
.into_iter()
.map(|(name, _, factor)| (name, factor))
.collect();
let one_to_one = scale_name_for_factor(&scales, 1.0)
.or_else(|| scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".into());
let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model";
let plot_views = self.tabs[self.active_tab]
.scene
.document
.views
.iter()
.filter(|view| view.paper_space == paper_space)
.map(|view| view.name.clone())
.collect();
// Keep session-only choices while loading drawing fields from the layout.
let d = &mut self.plot_dialog;
d.printers = crate::io::print_to_printer::list_printers();
d.plot_styles = crate::io::plot_style::available_ctb_names();
d.scales = scales;
d.plot_views = plot_views;
if d.scale.eq_ignore_ascii_case("fit") {
d.fit_to_paper = true;
d.scale_lw = false;
d.scale = one_to_one.clone();
} else if !d.scales.iter().any(|(name, _)| name == &d.scale) {
d.scale = one_to_one.clone();
}
d.paper_space = paper_space;
d.paper = self.plot_format.label().to_string();
d.orientation = match self.plot_orientation {
Orientation::Portrait => "Portrait",
Orientation::Landscape => "Landscape",
}
.to_string();
d.quality = match d.quality.as_str() {
"Low" | "Draft" => "Low",
"High" | "Presentation" | "Maximum" | "Custom" => "High",
_ => "Normal",
}
.into();
d.shade = match d.shade.as_str() {
"Wireframe" => "2D Wireframe",
value if value.starts_with("As displayed") => "As displayed",
"2D Wireframe"
| "3D Wireframe"
| "Hidden Line"
| "Flat Shaded"
| "Gouraud Shaded"
| "Flat Shaded + Edges"
| "Gouraud Shaded + Edges" => d.shade.as_str(),
_ => "As displayed",
}
.into();
d.style_name = self
.active_plot_style
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_default();
d.style_missing = false;
self.plot_dialog.name_input = None;
self.plot_dialog.name_rename = false;
if self.plot_dialog.fit_to_paper {
self.plot_dialog.scale_lw = false;
}
self.refresh_page_setups();
self.plot_prev = Some(previous);
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
let layout_entry = format!("*{cur}*");
if self.tabs[self.active_tab]
.scene
.plot_settings_for(&cur)
.is_some()
{
self.select_page_setup(&layout_entry);
} else {
self.select_page_setup(crate::ui::window::plot::SETUP_PREV);
}
if self.plot_dialog.scale.eq_ignore_ascii_case("fit") {
self.plot_dialog.fit_to_paper = true;
self.plot_dialog.scale = one_to_one.clone();
} else if !self
.plot_dialog
.scales
.iter()
.any(|(name, _)| name == &self.plot_dialog.scale)
{
self.plot_dialog.scale = one_to_one;
}
if self.plot_dialog.fit_to_paper {
self.plot_dialog.scale_lw = false;
}
// A model-space plot cannot use the paper-only Layout area.
if cur == "Model" {
if self.plot_dialog.area == "Layout" {
self.plot_dialog.area = "Window".into();
self.normalize_common_plot_dialog();
}
}
self.active_modal = Some(crate::app::ModalKind::Plot);
Task::none()
}
fn normalize_common_plot_dialog(&mut self) {
self.plot_dialog.center = true;
self.plot_dialog.offset_x = "0.0".into();
self.plot_dialog.offset_y = "0.0".into();
self.plot_dialog.fit_to_paper = true;
self.plot_dialog.scale_lw = false;
}
/// Handle one edit / action from the Plot dialog.
pub(super) fn on_plot_dlg(
&mut self,
msg: crate::ui::window::plot::PlotDlgMsg,
) -> Task<Message> {
use crate::ui::window::plot::{
PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, STYLE_NONE,
};
match msg {
M::Close => {
self.close_active_modal();
Task::none()
}
M::Printer(s) => {
if s == OUT_PDF {
self.plot_dialog.to_file = true;
} else if s == OUT_DEFAULT {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = None;
} else {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = Some(s);
}
Task::none()
}
M::PrinterProperties => {
match crate::io::print_to_printer::open_printer_properties(
self.plot_dialog.printer.as_deref(),
) {
Ok(()) => self.command_line.push_info(crate::t!("Opened printer properties.").as_ref()),
Err(error) => self.command_line.push_error(&error),
}
Task::none()
}
M::Paper(s) => {
self.plot_dialog.paper = s;
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
self.plot_dialog.paper_width_mm = w;
self.plot_dialog.paper_height_mm = h;
Task::none()
}
M::Orientation(s) => {
self.plot_dialog.orientation = s;
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
self.plot_dialog.paper_width_mm = w;
self.plot_dialog.paper_height_mm = h;
Task::none()
}
M::Area(s) => {
if self.print_all_options && s != "Layout" {
return Task::none();
}
// A paper Layout page setup commonly carries physical-sheet
// origin and 1:1 scale. Those values are correct only for
// Layout; carrying them into Window/Extents/Display moves a
// large selected area off-page. Enter the common area modes
// with centered, fit-to-paper defaults.
if self.plot_dialog.area == "Layout" && s != "Layout" {
self.normalize_common_plot_dialog();
}
if s == "Layout" {
self.plot_dialog.center = false;
self.plot_dialog.offset_x = "0.0".into();
self.plot_dialog.offset_y = "0.0".into();
self.plot_dialog.fit_to_paper = false;
self.plot_dialog.scale = scale_name_for_factor(
&self.plot_dialog.scales,
1.0,
)
.unwrap_or_else(|| "1:1".into());
}
self.plot_dialog.area = s;
Task::none()
}
M::Scale(s) => {
if self.plot_dialog.area != "Layout" {
self.plot_dialog.scale = s;
}
Task::none()
}
M::Quality(s) => {
self.plot_dialog.quality = s;
Task::none()
}
M::Shade(s) => {
self.plot_dialog.shade = s;
Task::none()
}
M::Copies(s) => {
self.plot_dialog.copies = s;
Task::none()
}
M::OffsetX(s) => {
self.plot_dialog.offset_x = s;
Task::none()
}
M::OffsetY(s) => {
self.plot_dialog.offset_y = s;
Task::none()
}
M::Flag(f) => {
let d = &mut self.plot_dialog;
match f {
PlotFlag::Background => d.background = !d.background,
PlotFlag::MergeLines => d.merge_lines = !d.merge_lines,
PlotFlag::FitToPaper if d.area != "Layout" => {
d.fit_to_paper = !d.fit_to_paper;
if d.fit_to_paper {
d.scale_lw = false;
}
}
PlotFlag::Center if d.area != "Layout" => d.center = !d.center,
PlotFlag::ScaleLw if !d.fit_to_paper => {
d.scale_lw = !d.scale_lw
}
PlotFlag::PlotStyles if !d.style_name.is_empty() => {
d.apply_plot_styles = !d.apply_plot_styles
}
PlotFlag::DisplayStyles if d.paper_space && !d.style_name.is_empty() => {
d.show_plot_styles = !d.show_plot_styles
}
PlotFlag::UpsideDown => {
d.upside_down = !d.upside_down;
}
PlotFlag::Lineweights => d.lineweights = !d.lineweights,
PlotFlag::Transparency => d.transparency = !d.transparency,
PlotFlag::PaperspaceLast if d.paper_space => {
d.paperspace_last = !d.paperspace_last
}
PlotFlag::Stamp => d.stamp = !d.stamp,
_ => {}
}
Task::none()
}
M::LoadStyle => Task::done(Message::PlotStyleLoad),
M::SaveStyle => Task::done(Message::PlotStylePanelSave),
M::Style(name) => {
if name == STYLE_NONE {
self.active_plot_style = None;
self.plot_dialog.style_name.clear();
self.plot_dialog.apply_plot_styles = false;
self.plot_dialog.show_plot_styles = false;
self.plot_dialog.style_missing = false;
} else {
match crate::io::plot_style::PlotStyleTable::load_named(&name) {
Ok(table) => {
self.plot_dialog.style_name = table.name.clone();
self.plot_dialog.apply_plot_styles = true;
self.plot_dialog.style_missing = false;
self.active_plot_style = Some(table);
}
Err(error) => {
self.plot_dialog.style_name = name;
self.plot_dialog.style_missing = true;
self.command_line.push_error(&error);
}
}
}
Task::none()
}
M::PickWindow => {
if self.print_all_options {
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.current_layout != "Model"
&& self.tabs[i].scene.active_viewport.is_some()
{
// A layout Window is selected in paper coordinates and may
// extend anywhere on the canvas, including outside the sheet.
// Leave MSPACE so the floating viewport cannot constrain the
// two picks to its model view.
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.active_viewport = None;
self.adopt_view_display(i);
self.tabs[i].refresh_active_ucs();
self.refresh_properties();
self.sync_dyn_fields();
}
self.close_active_modal();
Task::done(Message::Command("PLOTWINDOW".into()))
}
M::SelectSetup(name) => {
self.select_page_setup(&name);
if self.print_all_options {
self.plot_dialog.paper_space = true;
self.plot_dialog.area = "Layout".into();
}
Task::none()
}
M::SetCurrent => {
self.apply_dialog_to_layout();
self.command_line.push_info(crate::t!("Page setup applied to the layout.").as_ref());
Task::none()
}
M::NewSetup => {
if self.print_all_options {
return Task::none();
}
// Create a setup from the current editor values, then start an
// inline rename so the user can name it.
let name = self.next_page_setup_name("Setup");
let ps = self.dialog_to_plotsettings();
self.tabs[self.active_tab].scene.page_setup_save(&name, ps);
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup = name.clone();
self.refresh_page_setups();
self.plot_dialog.name_input = Some(name);
self.plot_dialog.name_rename = true;
Task::none()
}
M::CopySetup => {
if self.print_all_options {
return Task::none();
}
// Duplicate the selected entry — a layout OR a named setup —
// into a new standalone named page setup.
let sel = self.plot_dialog.selected_setup.clone();
let scene = &self.tabs[self.active_tab].scene;
let (src_ps, base) = if is_layout_entry(&sel) {
let ln = layout_entry_name(&sel);
(scene.plot_settings_for(ln), format!("{ln} copy"))
} else {
(scene.page_setup_get(&sel), format!("{sel} copy"))
};
if let Some(ps) = src_ps {
let name = self.next_page_setup_name(&base);
self.tabs[self.active_tab].scene.page_setup_save(&name, ps);
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup = name.clone();
self.refresh_page_setups();
self.plot_dialog.name_input = Some(name);
self.plot_dialog.name_rename = true;
}
Task::none()
}
M::RenameStart(name) => {
if self.print_all_options {
return Task::none();
}
// Only standalone named setups can be renamed.
if is_layout_entry(&name) || is_special_entry(&name) {
return Task::none();
}
self.plot_dialog.selected_setup = name.clone();
if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(&name) {
self.load_plotsettings_into_dialog(&ps);
}
self.plot_dialog.name_input = Some(name);
self.plot_dialog.name_rename = true;
Task::none()
}
M::DeleteSetup => {
if self.print_all_options {
return Task::none();
}
let sel = self.plot_dialog.selected_setup.clone();
if !sel.is_empty() && !is_layout_entry(&sel) {
self.tabs[self.active_tab].scene.page_setup_delete(&sel);
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup.clear();
self.refresh_page_setups();
}
Task::none()
}
M::NameInput(s) => {
self.plot_dialog.name_input = Some(s);
Task::none()
}
M::NameCommit => {
if let Some(name) = self.plot_dialog.name_input.take() {
let name = name.trim().to_string();
if !name.is_empty() {
if self.plot_dialog.name_rename {
let old = self.plot_dialog.selected_setup.clone();
self.tabs[self.active_tab].scene.page_setup_rename(&old, &name);
} else {
let ps = self.dialog_to_plotsettings();
self.tabs[self.active_tab].scene.page_setup_save(&name, ps);
}
self.tabs[self.active_tab].dirty = true;
self.plot_dialog.selected_setup = name;
self.refresh_page_setups();
}
}
self.plot_dialog.name_rename = false;
Task::none()
}
M::NameCancel => {
self.plot_dialog.name_input = None;
self.plot_dialog.name_rename = false;
Task::none()
}
M::Preview => self.on_plot_dlg_commit(true),
M::Commit if self.print_all_options => {
if self.plot_dialog.style_missing && self.plot_dialog.apply_plot_styles {
self.command_line.push_error(crate::tf!(
"Plot style table '{}' is not loaded.",
self.plot_dialog.style_name
).as_ref());
return Task::none();
}
self.plot_dialog.paper_space = true;
self.plot_dialog.area = "Layout".into();
self.sync_dialog_plot_runtime();
self.save_config();
self.print_all_settings_override = true;
self.print_all_options = false;
self.print_all_options_prev = None;
self.print_all_plot_style_prev = None;
self.print_all_plot_setup_prev = None;
if let Some(previous) = self.print_all_plot_window_prev.take() {
self.plot_window = previous;
}
self.active_modal = Some(crate::app::ModalKind::PrintAll);
self.reset_modal_geometry();
Task::none()
}
M::Commit => self.on_plot_dlg_commit(false),
}
}
fn refresh_page_setups(&mut self) {
use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV};
let scene = &self.tabs[self.active_tab].scene;
// <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 {
self.plot_setup_template = None;
// No page setup: default geometry + PDF output.
let is_model = self.tabs[self.active_tab].scene.current_layout == "Model";
let d = &mut self.plot_dialog;
d.to_file = true;
d.paper = "A4".into();
d.orientation = "Landscape".into();
d.paper_width_mm = 297.0;
d.paper_height_mm = 210.0;
d.area = if is_model { "Window".into() } else { "Layout".into() };
d.center = true;
d.offset_x = "0.0".into();
d.offset_y = "0.0".into();
d.upside_down = false;
d.fit_to_paper = is_model;
d.scale_lw = false;
d.scale = scale_name_for_factor(&d.scales, 1.0)
.or_else(|| d.scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".into());
} else if name == SETUP_PREV {
if let Some(prev) = self.plot_prev.clone() {
self.plot_dialog.copy_settings_from(&prev);
}
} else if is_layout_entry(name) {
if let Some(ps) = self
.tabs[self.active_tab]
.scene
.plot_settings_for(layout_entry_name(name))
{
self.load_plotsettings_into_dialog(&ps);
}
} else if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(name) {
self.load_plotsettings_into_dialog(&ps);
}
}
/// A page-setup name based on `base` that isn't already taken (`base`,
/// `base 2`, `base 3`, …).
fn next_page_setup_name(&self, base: &str) -> String {
let existing = self.tabs[self.active_tab].scene.page_setup_names();
if !existing.iter().any(|n| n == base) {
return base.to_string();
}
(2..)
.map(|i| format!("{base} {i}"))
.find(|c| !existing.iter().any(|n| n == c))
.unwrap_or_else(|| base.to_string())
}
/// Build a `PlotSettings` from the current dialog fields (for saving a named
/// page setup).
fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings {
use acadrust::objects::{
PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode,
ShadePlotResolutionLevel,
};
let d = &self.plot_dialog;
let (w, h) = plot_dialog_sheet_mm(d);
let mut ps = match self.plot_setup_template.clone() {
Some(settings) => settings,
None => {
let mut settings = PlotSettings::new("");
settings.paper_units = PlotPaperUnits::Millimeters;
settings
}
};
ps.paper_width = w;
ps.paper_height = h;
ps.paper_size = d.paper.clone();
ps.plot_type = match d.area.as_str() {
"Window" => PlotType::Window,
"Layout" => PlotType::Layout,
"Display" => PlotType::LastScreenDisplay,
"Limits" => PlotType::Limits,
area if area.starts_with("View: ") => PlotType::View,
_ => PlotType::Extents,
};
ps.plot_view_name = d
.area
.strip_prefix("View: ")
.unwrap_or("")
.to_string();
if d.area == "Window" {
if let Some((x0, y0, x1, y1)) = self.plot_window {
ps.set_plot_window(x0, y0, x1, y1);
}
}
ps.flags.plot_centered = d.center && d.area != "Layout";
ps.origin_x = if d.area == "Layout" {
0.0
} else {
d.offset_x.parse::<f64>().unwrap_or(0.0)
};
ps.origin_y = if d.area == "Layout" {
0.0
} else {
d.offset_y.parse::<f64>().unwrap_or(0.0)
};
let rot: i16 = if d.upside_down { 180 } else { 0 };
ps.rotation = match rot {
90 => PlotRotation::Degrees90,
180 => PlotRotation::Degrees180,
270 => PlotRotation::Degrees270,
_ => PlotRotation::None,
};
if d.area == "Layout" {
ps.set_standard_scale(ScaledType::OneToOne);
ps.standard_scale_factor = 1.0;
} else if d.fit_to_paper {
ps.set_scale_to_fit();
} else {
let factor = plot_dialog_scale_factor(d);
ps.scale_type = ScaledType::CustomScale;
ps.scale_numerator = factor;
ps.scale_denominator = 1.0;
ps.standard_scale_factor = factor;
ps.flags.use_standard_scale = false;
}
ps.printer_name = if d.to_file {
crate::ui::window::plot::OUT_PDF.into()
} else {
d.printer.clone().unwrap_or_default()
};
ps.current_style_sheet = d.style_name.clone();
ps.flags.scale_lineweights = d.scale_lw;
ps.flags.print_lineweights = d.lineweights;
ps.flags.plot_plot_styles = d.apply_plot_styles && !d.style_name.is_empty();
ps.flags.show_plot_styles = d.show_plot_styles && !d.style_name.is_empty();
ps.flags.draw_viewports_first = d.paperspace_last;
ps.flags.plot_hidden = d.shade == "Hidden Line";
ps.shade_plot_mode = match d.shade.as_str() {
"2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
"Hidden Line" => ShadePlotMode::Hidden,
"As displayed" => ShadePlotMode::AsDisplayed,
_ => ShadePlotMode::Rendered,
};
ps.shade_plot_resolution = match d.quality.as_str() {
"Low" => ShadePlotResolutionLevel::Draft,
"High" => ShadePlotResolutionLevel::Presentation,
_ => ShadePlotResolutionLevel::Normal,
};
ps.shade_plot_dpi = 300;
ps
}
/// Load a `PlotSettings` into the dialog editor fields.
fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) {
use acadrust::objects::{
PlotType, ShadePlotMode, ShadePlotResolutionLevel,
};
self.plot_setup_template = Some(ps.clone());
if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() {
self.plot_window = Some((
ps.plot_window.lower_left_x,
ps.plot_window.lower_left_y,
ps.plot_window.upper_right_x,
ps.plot_window.upper_right_y,
));
}
if !ps.current_style_sheet.is_empty()
&& self
.active_plot_style
.as_ref()
.is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet))
{
if let Ok(table) = crate::io::plot_style::PlotStyleTable::load_named(
&ps.current_style_sheet,
) {
self.active_plot_style = Some(table);
}
}
let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone());
let style_name = if ps.current_style_sheet.is_empty() {
String::new()
} else if active_style_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(&ps.current_style_sheet))
{
active_style_name.clone().unwrap_or_default()
} else {
ps.current_style_sheet.clone()
};
let style_loaded = !style_name.is_empty()
&& active_style_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(&style_name));
let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height);
if !matches!(
paper.as_str(),
"A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid"
)
&& !ps.paper_size.is_empty()
{
paper = ps.paper_size.clone();
}
let d = &mut self.plot_dialog;
d.paper = paper;
d.paper_width_mm = ps.paper_width.max(1.0);
d.paper_height_mm = ps.paper_height.max(1.0);
d.orientation = orient;
d.area = match ps.plot_type {
PlotType::Window => "Window".to_string(),
PlotType::Layout => "Layout".to_string(),
PlotType::LastScreenDisplay => "Display".to_string(),
PlotType::Limits => "Limits".to_string(),
PlotType::View if !ps.plot_view_name.is_empty() => {
d.plot_views.push(ps.plot_view_name.clone());
d.plot_views.sort();
d.plot_views.dedup();
format!("View: {}", ps.plot_view_name)
}
_ => "Extents".to_string(),
};
if !d.paper_space && d.area == "Layout" {
d.area = "Extents".into();
}
d.center = ps.flags.plot_centered;
d.offset_x = format!("{:.2}", ps.origin_x);
d.offset_y = format!("{:.2}", ps.origin_y);
let deg = ps.rotation.to_degrees() as i32;
if matches!(deg, 90 | 270) {
d.orientation = if d.orientation == "Portrait" {
"Landscape"
} else {
"Portrait"
}
.into();
}
d.upside_down = matches!(deg, 180 | 270);
d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit();
let target_factor = if d.area == "Layout" {
1.0
} else if ps.flags.use_standard_scale {
if ps.standard_scale_factor.is_finite() && ps.standard_scale_factor > 0.0 {
ps.standard_scale_factor
} else {
ps.scale_type.scale_factor()
}
} else if ps.scale_denominator.abs() > 1e-9 {
ps.scale_numerator / ps.scale_denominator
} else {
1.0
};
if !d.fit_to_paper || !d.scales.iter().any(|(name, _)| name == &d.scale) {
d.scale = scale_name_for_factor(&d.scales, target_factor)
.or_else(|| scale_name_for_factor(&d.scales, 1.0))
.or_else(|| d.scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".into());
}
d.scale_lw = ps.flags.scale_lineweights && !d.fit_to_paper;
d.lineweights = ps.flags.print_lineweights;
d.paperspace_last = ps.flags.draw_viewports_first;
d.shade = if ps.flags.plot_hidden {
"Hidden Line"
} else {
match ps.shade_plot_mode {
ShadePlotMode::Wireframe => "2D Wireframe",
ShadePlotMode::Hidden => "Hidden Line",
ShadePlotMode::Rendered => "Gouraud Shaded",
ShadePlotMode::AsDisplayed => "As displayed",
}
}
.into();
d.quality = match ps.shade_plot_resolution {
ShadePlotResolutionLevel::Draft => "Low",
ShadePlotResolutionLevel::Presentation
| ShadePlotResolutionLevel::Maximum
| ShadePlotResolutionLevel::Custom => "High",
_ => "Normal",
}
.into();
if ps.printer_name.to_ascii_lowercase().contains("pdf") {
d.to_file = true;
d.printer = None;
} else {
d.to_file = false;
d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone());
}
d.style_name = style_name;
d.apply_plot_styles = ps.flags.plot_plot_styles;
d.show_plot_styles = ps.flags.show_plot_styles;
d.style_missing = !d.style_name.is_empty() && !style_loaded;
}
/// Copy transient paper/scale choices out of the dialog without changing
/// the active layout.
fn sync_dialog_plot_runtime(&mut self) {
use crate::io::paper_sizes::{Orientation, PaperSize};
let d = self.plot_dialog.clone();
let paper = match d.paper.as_str() {
"A3" => PaperSize::A3,
"A2" => PaperSize::A2,
"A1" => PaperSize::A1,
"A0" => PaperSize::A0,
"Letter" => PaperSize::Letter,
"Legal" => PaperSize::Legal,
"Tabloid" => PaperSize::Tabloid,
_ => PaperSize::A4,
};
let orient = if d.orientation == "Portrait" {
Orientation::Portrait
} else {
Orientation::Landscape
};
self.plot_format = paper;
self.plot_orientation = orient;
}
fn apply_dialog_to_layout(&mut self) {
self.sync_dialog_plot_runtime();
let d = self.plot_dialog.clone();
let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&d);
let rotation: i16 = if d.upside_down { 180 } else { 0 };
let off_x = d.offset_x.parse::<f64>().unwrap_or(0.0);
let off_y = d.offset_y.parse::<f64>().unwrap_or(0.0);
self.apply_plot_page_settings(
sheet_w,
sheet_h,
&d.area,
d.center,
off_x,
off_y,
rotation,
);
}
/// Open a preview PDF, export a PDF, or send the job to the chosen printer.
fn on_plot_dlg_commit(&mut self, preview: bool) -> Task<Message> {
let d = self.plot_dialog.clone();
if d.style_missing && d.apply_plot_styles {
self.command_line.push_error(crate::tf!(
"Plot style table '{}' is not loaded.",
d.style_name
).as_ref());
return Task::none();
}
// Remember the user's print preferences across sessions.
self.save_config();
// Preview and normal printing must not resize/mutate the live layout;
// the runtime paper/scale choices still drive this one plot operation.
self.sync_dialog_plot_runtime();
self.active_modal = None;
self.reset_modal_geometry();
let plot_style = self.dialog_plot_style(&d);
// Extents, Window and Display use one plot path in both spaces. Only
// Paper-space Layout is special: it uses the physical sheet bounds.
if d.area != "Layout" {
let job = match d.area.as_str() {
"Display" => self.display_plot_job(),
"Extents" => self.extents_plot_job(),
"Limits" => self.limits_plot_job(),
"Window" => self.window_plot_job(),
area if area.starts_with("View: ") => {
self.named_view_plot_job(area.trim_start_matches("View: "))
}
_ => None,
};
let Some((
w_wires,
w_hatches,
w_wipeouts,
wgroup_splits,
sw,
sh,
wox,
woy,
wrotation,
wscale,
wclip,
)) = job
else {
self.command_line
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
self.active_modal = Some(crate::app::ModalKind::Plot);
return Task::none();
};
// Preview renders to a temp PDF and opens it — never a save dialog,
// whatever the output target is.
if preview {
let tmp = crate::io::print_to_printer::temp_pdf_path("preview");
let render_options = Self::pdf_plot_options(&d, wgroup_splits);
let work = move || {
crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp,
plot_style.as_ref(),
render_options,
).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
};
return self.run_plot_work(d.background, true, work);
}
if d.to_file {
// Tested clipped export (opens a save dialog).
return Task::done(Message::PlotWindowExport);
}
let tmp = crate::io::print_to_printer::temp_pdf_path("print");
let render_options = Self::pdf_plot_options(&d, wgroup_splits);
let opts = self.plot_print_options(&d, wgroup_splits);
let work = move || {
crate::io::pdf_export::export_pdf(
&w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp,
plot_style.as_ref(),
render_options,
).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts))
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
.map_err(|e| crate::tf!("Print failed: {e}").into_owned())
};
return self.run_plot_work(true, false, work);
}
let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) =
self.layout_plot_params();
if preview {
let tmp = crate::io::print_to_printer::temp_pdf_path("preview");
let render_options = Self::pdf_plot_options(&d, group_splits);
let work = move || {
crate::io::pdf_export::export_pdf(
&wires,
&hatches,
&wipeouts,
page_w,
page_h,
ox,
oy,
rotation,
scale,
clip,
&tmp,
plot_style.as_ref(),
render_options,
).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp))
.map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
};
return self.run_plot_work(d.background, true, work);
}
if d.to_file {
// Reuse the tested PDF export flow (it opens a save dialog).
return Task::done(Message::PlotExport);
}
let opts = self.plot_print_options(&d, group_splits);
self.command_line.push_info(crate::t!("Sending to system printer…").as_ref());
let work = move || {
iced::futures::executor::block_on(
crate::io::print_to_printer::print_wires_with(
wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip,
plot_style, opts,
))
.map(|printer| crate::tf!("Sent to printer: {printer}").into_owned())
.map_err(|error| crate::tf!("Print failed: {error}").into_owned())
};
self.run_plot_work(true, false, work)
}
/// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from
/// the dialog state.
fn plot_print_options(
&self,
d: &crate::ui::window::plot::PlotDialogState,
group_splits: crate::io::pdf_export::PlotGroupSplits,
) -> crate::io::print_to_printer::PrintOptions {
crate::io::print_to_printer::PrintOptions {
printer: d.printer.clone(),
copies: d.copies.trim().parse::<u32>().unwrap_or(1).max(1),
quality: Some(d.quality.clone()),
render: Self::pdf_plot_options(d, group_splits),
}
}
fn pdf_plot_options(
d: &crate::ui::window::plot::PlotDialogState,
group_splits: crate::io::pdf_export::PlotGroupSplits,
) -> crate::io::pdf_export::PdfPlotOptions {
crate::io::pdf_export::PdfPlotOptions {
object_lineweights: d.lineweights,
scale_lineweights: d.scale_lw && !d.fit_to_paper,
transparency: d.transparency,
stamp: d.stamp,
merge_lines: d.merge_lines,
group_splits,
}
}
fn dialog_plot_style(
&self,
d: &crate::ui::window::plot::PlotDialogState,
) -> Option<crate::io::plot_style::PlotStyleTable> {
if d.style_name.is_empty() || d.style_missing || !d.apply_plot_styles {
return None;
}
self.active_plot_style
.as_ref()
.filter(|table| table.name.eq_ignore_ascii_case(&d.style_name))
.cloned()
}
fn window_plot_job(&self) -> Option<ClippedPlotParams> {
self.area_plot_job(self.plot_window?)
}
fn display_plot_job(&self) -> Option<ClippedPlotParams> {
self.area_plot_job(self.display_plot_window()?)
}
fn limits_plot_job(&self) -> Option<ClippedPlotParams> {
let (min, max) = self.tabs[self.active_tab]
.scene
.current_drawing_limits()?;
self.area_plot_job((min.x, min.y, max.x, max.y))
}
fn named_view_plot_job(&self, name: &str) -> Option<ClippedPlotParams> {
let view = self.tabs[self.active_tab]
.scene
.document
.views
.iter()
.find(|view| {
view.name.eq_ignore_ascii_case(name)
&& view.paper_space == (self.tabs[self.active_tab].scene.current_layout != "Model")
})?;
let half_w = view.width.abs() * 0.5;
let half_h = view.height.abs() * 0.5;
(half_w > 1e-9 && half_h > 1e-9).then_some(())?;
self.area_plot_job((
view.center.x - half_w,
view.center.y - half_h,
view.center.x + half_w,
view.center.y + half_h,
))
}
fn extents_plot_job(&self) -> Option<ClippedPlotParams> {
let scene = &self.tabs[self.active_tab].scene;
if scene.current_layout == "Model" {
let (min, max) = scene.model_space_extents()?;
return self.area_plot_job((
min.x as f64,
min.y as f64,
max.x as f64,
max.y as f64,
));
}
let (wires, hatches, wipeouts, _) =
plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
let extents = plot_content_extents(
&wires
.iter()
.filter(|wire| wire.name != "paper_printable_area")
.cloned()
.collect::<Vec<_>>(),
&hatches,
&wipeouts,
)?;
self.area_plot_job(extents)
}
/// Current visible rectangle in the active space. The result is deliberately
/// not clamped to the paper sheet: Display and Window may include the grey
/// canvas outside the sheet, matching Model-space plotting.
fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> {
let scene = &self.tabs[self.active_tab].scene;
let (canvas_w, canvas_h) = scene.selection.borrow().vp_size;
let viewport = if scene.current_layout == "Model" {
scene.active_model_tile_bounds(canvas_w, canvas_h)
} else {
iced::Rectangle {
x: 0.0,
y: 0.0,
width: canvas_w,
height: canvas_h,
}
};
if viewport.width < 1.0 || viewport.height < 1.0 {
return None;
}
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: viewport.width,
height: viewport.height,
};
let camera = scene.camera.borrow();
let mut x0 = f64::INFINITY;
let mut y0 = f64::INFINITY;
let mut x1 = f64::NEG_INFINITY;
let mut y1 = f64::NEG_INFINITY;
for point in [
iced::Point::new(0.0, 0.0),
iced::Point::new(bounds.width, 0.0),
iced::Point::new(bounds.width, bounds.height),
iced::Point::new(0.0, bounds.height),
] {
let world = camera.unproject_on_plane(
point,
bounds,
glam::Vec3::Z,
glam::DVec3::ZERO,
);
x0 = x0.min(world.x);
y0 = y0.min(world.y);
x1 = x1.max(world.x);
y1 = y1.max(world.y);
}
(x0.is_finite()
&& y0.is_finite()
&& x1.is_finite()
&& y1.is_finite()
&& x1 - x0 > 1e-6
&& y1 - y0 > 1e-6)
.then_some((x0, y0, x1, y1))
}
/// Render a selected rectangle through one shared Model/Paper path. The
/// window may lie partly or wholly outside a paper sheet.
fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option<ClippedPlotParams> {
use crate::io::paper_sizes::{window_to_sheet, PlotScale};
let i = self.active_tab;
let (x0, y0, x1, y1) = window;
if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 {
return None;
}
let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&self.plot_dialog);
let win_w = (x1 - x0).max(1e-9);
let win_h = (y1 - y0).max(1e-9);
let scale_sel = if self.plot_dialog.fit_to_paper {
PlotScale::Fit
} else {
let factor = plot_dialog_scale_factor(&self.plot_dialog);
if factor > 0.0 {
let mm_per_unit = if self.tabs[i].scene.current_layout == "Model" {
1.0
} else {
1.0 / self.tabs[i].scene.paper_space_unit_factor().max(1e-9)
};
PlotScale::Ratio(factor * mm_per_unit)
} else {
PlotScale::Fit
}
};
let (scale, centered_x, centered_y) =
window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel);
let target_x = if self.plot_dialog.center {
centered_x
} else {
self.plot_dialog.offset_x.parse::<f64>().unwrap_or(0.0)
};
let target_y = if self.plot_dialog.center {
centered_y
} else {
self.plot_dialog.offset_y.parse::<f64>().unwrap_or(0.0)
};
let scene = &self.tabs[i].scene;
let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32);
let (all_wires, hatches, wipeouts, mut group_splits) =
plot_scene_content(
scene,
self.plot_dialog.paperspace_last,
plot_render_mode_override(&self.plot_dialog),
);
let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())]
.iter()
.filter(|w| w.name != "paper_printable_area")
.filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0)
.count();
let wires: Vec<_> = all_wires
.iter()
.filter(|w| w.name != "paper_printable_area")
.filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0)
.cloned()
.collect();
group_splits.wires = first_wire_count;
let offset_x = (target_x / scale) - x0;
let offset_y = (target_y / scale) - y0;
let clip = Some((
(target_x / scale) as f32,
(target_y / scale) as f32,
win_w as f32,
win_h as f32,
));
let rotation = if self.plot_dialog.upside_down { 180 } else { 0 };
let (page_w, page_h) = match rotation {
90 | 270 => (sheet_h, sheet_w),
_ => (sheet_w, sheet_h),
};
Some((
wires,
hatches,
wipeouts,
group_splits,
page_w,
page_h,
offset_x,
offset_y,
rotation,
scale as f32,
clip,
))
}
pub(super) fn on_plot_style_panel_apply(&mut self) -> Task<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);
}
}
} 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(crate::tf!("Created new CTB table, ACI {aci} updated.").as_ref());
}
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(crate::t!("No plot style table loaded. Load or create one first.").as_ref());
return Task::none();
}
let default_name = self
.active_plot_style
.as_ref()
.map(|t| t.name.clone())
.unwrap_or("export.ctb".into());
Task::perform(
async move {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!("Save Plot Style Table").as_ref())
.set_file_name(&default_name)
.add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"])
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
#[cfg(not(target_arch = "wasm32"))]
let dialog = match crate::io::plot_style::ensure_plot_styles_dir() {
Ok(dir) => dialog.set_directory(dir),
Err(_) => dialog,
};
dialog.save_file().await
.map(|h| crate::sys::handle_path(&h))
},
Message::PlotStylePanelSavePath,
)
}
}