forked from 1b-agent/opencadstudio-upstream
4896 lines
201 KiB
Rust
4896 lines
201 KiB
Rust
//! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
||
|
||
#![allow(unused_imports)]
|
||
use super::util::*;
|
||
use super::{format_size, VIEWCUBE_HIT_SIZE};
|
||
use crate::app::helpers::{
|
||
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,
|
||
)
|
||
}
|
||
}
|