forked from 1b-agent/opencadstudio-upstream
The web control channel gains a save operation that serializes the active drawing in the requested format and returns it as base64 in the reply, clearing the dirty flag, the counterpart of opening from bytes. A host page can also define window.openCadStudioSaveFile(name, bytes) to receive every saved or exported file in place of a browser download; while it is set no file picker opens. Fixes #1704 Fixes #1705 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8782 lines
374 KiB
Rust
8782 lines
374 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::model::object::GripApply;
|
||
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
||
use crate::scene::{
|
||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||
};
|
||
use crate::ui::PropertiesPanel;
|
||
use codec::types::Color as AcadColor;
|
||
use codec::{EntityType as AcadEntityType, Handle};
|
||
use iced::time::Instant;
|
||
use iced::{mouse, Point, Task};
|
||
|
||
/// Write an exported file: to disk on the desktop, as a browser download on
|
||
/// the web, where there is no filesystem behind the dialog's path. (#761)
|
||
fn write_export(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let name = path
|
||
.file_name()
|
||
.map_or_else(|| "export".into(), |n| n.to_string_lossy());
|
||
crate::sys::download_bytes(&name, bytes);
|
||
Ok(())
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
std::fs::write(path, bytes).map_err(|e| e.to_string())
|
||
}
|
||
}
|
||
|
||
/// Run `work` on a worker thread and map its result into a `Message`.
|
||
///
|
||
/// `context` names the operation for error reporting. A worker panic never
|
||
/// reaches the UI thread: it is caught on the worker and re-surfaced as
|
||
/// [`Message::BackgroundTaskFailed`] so a crashing export or import reports
|
||
/// on the command line instead of killing the application.
|
||
pub(in crate::app) fn background_task<T, F, M>(
|
||
context: impl Into<String>,
|
||
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 context = context.into();
|
||
let (tx, rx) = iced::futures::channel::oneshot::channel();
|
||
std::thread::spawn(move || {
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
|
||
let _ = tx.send(result);
|
||
});
|
||
Task::perform(
|
||
async move {
|
||
match rx.await {
|
||
Ok(Ok(value)) => map(value),
|
||
Ok(Err(payload)) => Message::BackgroundTaskFailed {
|
||
context,
|
||
detail: panic_detail(payload),
|
||
},
|
||
Err(_) => Message::BackgroundTaskFailed {
|
||
context,
|
||
detail: "worker stopped without reporting a result".to_string(),
|
||
},
|
||
}
|
||
},
|
||
|message| message,
|
||
)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = context;
|
||
Task::perform(async move { work() }, map)
|
||
}
|
||
}
|
||
|
||
/// Render a panic payload the way `std` reports it: `&'static str` messages,
|
||
/// `String` messages, or a fallback for foreign payloads.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn panic_detail(payload: Box<dyn std::any::Any + Send>) -> String {
|
||
if let Some(text) = payload.downcast_ref::<&'static str>() {
|
||
(*text).to_string()
|
||
} else if let Some(text) = payload.downcast_ref::<String>() {
|
||
text.clone()
|
||
} else {
|
||
"worker panicked".to_string()
|
||
}
|
||
}
|
||
|
||
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<codec::entities::ViewportRenderMode> {
|
||
use codec::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"))]
|
||
pub(in crate::app) 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,
|
||
}
|
||
}
|
||
|
||
use crate::io::pdf_export::{PdfPageInput, PlotContent};
|
||
|
||
/// The dialog's sheet orientation as a catalogue value.
|
||
fn plot_dialog_orientation(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
) -> crate::io::paper_catalog::Orientation {
|
||
use crate::io::paper_catalog::Orientation;
|
||
if d.orientation == "Portrait" {
|
||
Orientation::Portrait
|
||
} else {
|
||
Orientation::Landscape
|
||
}
|
||
}
|
||
|
||
/// The sheet the dialog names, or — when the name carries no size (an old
|
||
/// config, a bare driver name) — a custom sheet built from the dialog's stored
|
||
/// dimensions, so a drawing whose paper we cannot identify still plots at its
|
||
/// own size.
|
||
fn plot_dialog_paper(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
) -> crate::io::paper_catalog::PaperSize {
|
||
use crate::io::paper_catalog::{from_drawing, resolve, PaperSize, PaperUnits};
|
||
if d.paper_width_mm > 0.0 && d.paper_height_mm > 0.0 {
|
||
from_drawing(&d.paper, d.paper_width_mm, d.paper_height_mm)
|
||
} else {
|
||
resolve(&d.paper)
|
||
.unwrap_or_else(|| PaperSize::custom(210.0, 297.0, PaperUnits::Millimeters))
|
||
}
|
||
}
|
||
|
||
fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) {
|
||
plot_dialog_paper(d).sheet_mm(plot_dialog_orientation(d))
|
||
}
|
||
|
||
/// Whether the dialog still names the sheet the stored settings carry.
|
||
fn paper_unchanged(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
stored: &codec::objects::PlotSettings,
|
||
) -> bool {
|
||
!stored.paper_size.is_empty()
|
||
&& crate::io::paper_catalog::from_drawing(
|
||
&stored.paper_size,
|
||
stored.paper_width,
|
||
stored.paper_height,
|
||
)
|
||
.canonical
|
||
== d.paper
|
||
}
|
||
|
||
/// The media name to store for the dialog's sheet. A drawing keeps its own
|
||
/// spelling of a sheet the user did not change — a system printer's `A4` or a
|
||
/// plotter's custom name must not turn into the catalogue's canonical name
|
||
/// just by passing through the dialog, or the source application would look the sheet up
|
||
/// under a name that driver never reported. Only a genuinely different
|
||
/// selection writes the canonical name.
|
||
fn paper_name_for_settings(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
stored: &codec::objects::PlotSettings,
|
||
) -> String {
|
||
if paper_unchanged(d, stored) {
|
||
stored.paper_size.clone()
|
||
} else {
|
||
d.paper.clone()
|
||
}
|
||
}
|
||
|
||
/// The stored rotation for the dialog's sheet on a medium that is itself
|
||
/// `natural`-oriented: a quarter turn when the requested orientation differs
|
||
/// from the medium's own, and "upside-down" adds a half turn on top. This is
|
||
/// how the commercial application writes it — a system printer's portrait
|
||
/// `A3` plotted landscape carries 90°, while its PDF driver's landscape
|
||
/// `ISO_A4_(297.00_x_210.00_MM)` medium plotted landscape carries 0°.
|
||
fn plot_dialog_rotation(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
natural: crate::io::paper_catalog::Orientation,
|
||
) -> codec::objects::PlotRotation {
|
||
use codec::objects::PlotRotation;
|
||
match (plot_dialog_orientation(d) != natural, d.upside_down) {
|
||
(false, false) => PlotRotation::None,
|
||
(true, false) => PlotRotation::Degrees90,
|
||
(false, true) => PlotRotation::Degrees180,
|
||
(true, true) => PlotRotation::Degrees270,
|
||
}
|
||
}
|
||
|
||
/// Millimetres in the paper unit a page setup counts in.
|
||
fn plot_paper_unit_mm(units: codec::objects::PlotPaperUnits) -> f64 {
|
||
match units {
|
||
codec::objects::PlotPaperUnits::Inches => 25.4,
|
||
_ => 1.0,
|
||
}
|
||
}
|
||
|
||
/// The factor the commercial application stores next to the scale (DXF 147)
|
||
/// is the `paper : drawing` ratio times 25.4 on millimetre page setups and
|
||
/// the plain ratio on inch ones — observed in its files, whatever the
|
||
/// published description says. Both directions of that convention live here.
|
||
fn stored_scale_factor(ratio: f64, units: codec::objects::PlotPaperUnits) -> f64 {
|
||
match units {
|
||
codec::objects::PlotPaperUnits::Millimeters => ratio * 25.4,
|
||
_ => ratio,
|
||
}
|
||
}
|
||
|
||
fn ratio_from_stored_scale_factor(factor: f64, units: codec::objects::PlotPaperUnits) -> f64 {
|
||
match units {
|
||
codec::objects::PlotPaperUnits::Millimeters => factor / 25.4,
|
||
_ => factor,
|
||
}
|
||
}
|
||
|
||
/// The file format's standard scale for a `paper : drawing` ratio, when it
|
||
/// has one. Only the metric ratios exist as standard codes here; an
|
||
/// architectural scale is stored as a custom ratio with the same value.
|
||
fn standard_scale_for_ratio(paper: f64, drawing: f64) -> Option<codec::objects::ScaledType> {
|
||
use codec::objects::ScaledType as S;
|
||
const STANDARD: &[(f64, f64, S)] = &[
|
||
(1.0, 1.0, S::OneToOne),
|
||
(1.0, 2.0, S::OneToTwo),
|
||
(1.0, 4.0, S::OneToFour),
|
||
(1.0, 8.0, S::OneToEight),
|
||
(1.0, 10.0, S::OneToTen),
|
||
(1.0, 16.0, S::OneToSixteen),
|
||
(1.0, 20.0, S::OneToTwenty),
|
||
(1.0, 30.0, S::OneToThirty),
|
||
(1.0, 40.0, S::OneToForty),
|
||
(1.0, 50.0, S::OneToFifty),
|
||
(1.0, 100.0, S::OneToHundred),
|
||
(2.0, 1.0, S::TwoToOne),
|
||
(4.0, 1.0, S::FourToOne),
|
||
(8.0, 1.0, S::EightToOne),
|
||
(10.0, 1.0, S::TenToOne),
|
||
(100.0, 1.0, S::HundredToOne),
|
||
];
|
||
let ratio = paper / drawing;
|
||
STANDARD
|
||
.iter()
|
||
.find(|(p, d, _)| (p / d - ratio).abs() <= 1e-9 * ratio.abs().max(1.0))
|
||
.map(|(_, _, scale)| *scale)
|
||
}
|
||
|
||
/// `12.5000` → `12.5`, `3.0000` → `3`: a fixed-precision number without its
|
||
/// trailing zeros, for fields the user reads back.
|
||
fn trim_decimals(text: &str) -> String {
|
||
if text.contains('.') {
|
||
text.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
} else {
|
||
text.to_string()
|
||
}
|
||
}
|
||
|
||
/// A scale-list name for a plot scale the drawing has no name for, spelled as
|
||
/// the ratio it is (`1:250`, `25.4:1`) so the value survives in the picker
|
||
/// instead of collapsing to 1:1.
|
||
fn ratio_scale_name(factor: f64) -> String {
|
||
if factor >= 1.0 {
|
||
format!("{}:1", trim_decimals(&format!("{factor:.4}")))
|
||
} else {
|
||
format!("1:{}", trim_decimals(&format!("{:.4}", 1.0 / factor)))
|
||
}
|
||
}
|
||
|
||
/// The plot device the dialog currently targets.
|
||
fn plot_dialog_device(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
) -> crate::io::plot_device::PlotDevice {
|
||
use crate::io::plot_device::PlotDevice;
|
||
if d.to_file {
|
||
PlotDevice::Pdf
|
||
} else {
|
||
match &d.printer {
|
||
Some(name) => PlotDevice::Printer(name.clone()),
|
||
None => PlotDevice::None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The plotter name to store. Same rule as the sheet: a device name the source application
|
||
/// wrote (its PDF driver, a plotter `.pc3` we plot to PDF for it) stays as it
|
||
/// is while the dialog still points at the same kind of device; a changed
|
||
/// device, or one of our own legacy labels, is written in the source application's spelling.
|
||
fn device_name_for_settings(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
stored: &codec::objects::PlotSettings,
|
||
) -> String {
|
||
use crate::io::plot_device::PlotDevice;
|
||
let device = plot_dialog_device(d);
|
||
let (stored_device, keep_stored) = PlotDevice::from_stored_name(&stored.printer_name);
|
||
if keep_stored && stored_device == device {
|
||
stored.printer_name.clone()
|
||
} else {
|
||
device.canonical_name()
|
||
}
|
||
}
|
||
|
||
/// The unprintable margins to store: the drawing's own while neither the
|
||
/// sheet nor the device changed (they came from the source application's driver and we have
|
||
/// no better source), otherwise what our device reports for the new sheet —
|
||
/// the equivalent of the source application's `PAPERUPDATE`.
|
||
fn margins_for_settings(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
stored: &codec::objects::PlotSettings,
|
||
) -> codec::objects::PaperMargin {
|
||
use crate::io::plot_device::PlotDevice;
|
||
let device = plot_dialog_device(d);
|
||
let (stored_device, _) = PlotDevice::from_stored_name(&stored.printer_name);
|
||
if paper_unchanged(d, stored) && stored_device == device {
|
||
return stored.margins;
|
||
}
|
||
let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers);
|
||
codec::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top)
|
||
}
|
||
|
||
fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> {
|
||
let PlotContent {
|
||
wires,
|
||
hatches,
|
||
wipeouts,
|
||
images,
|
||
..
|
||
} = content;
|
||
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
|
||
.iter()
|
||
.filter(|wire| wire.name != "paper_printable_area")
|
||
{
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
for plot in images {
|
||
let mut image_bounds = [
|
||
f64::INFINITY,
|
||
f64::INFINITY,
|
||
f64::NEG_INFINITY,
|
||
f64::NEG_INFINITY,
|
||
];
|
||
for vertex in &plot.image.verts {
|
||
for axis in 0..2 {
|
||
let value = vertex.pos[axis] as f64 + vertex.pos_low[axis] as f64;
|
||
image_bounds[axis] = image_bounds[axis].min(value);
|
||
image_bounds[axis + 2] = image_bounds[axis + 2].max(value);
|
||
}
|
||
}
|
||
for clip in &plot.clips {
|
||
for axis in 0..2 {
|
||
let min = clip.iter().map(|p| p[axis]).fold(f64::INFINITY, f64::min);
|
||
let max = clip
|
||
.iter()
|
||
.map(|p| p[axis])
|
||
.fold(f64::NEG_INFINITY, f64::max);
|
||
image_bounds[axis] = image_bounds[axis].max(min);
|
||
image_bounds[axis + 2] = image_bounds[axis + 2].min(max);
|
||
}
|
||
}
|
||
if image_bounds[0] < image_bounds[2] && image_bounds[1] < image_bounds[3] {
|
||
include(image_bounds[0], image_bounds[1]);
|
||
include(image_bounds[2], image_bounds[3]);
|
||
}
|
||
}
|
||
(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)
|
||
}
|
||
|
||
/// Stamp the owning entity's effective color index onto a plot wire so CTB
|
||
/// plot-style lookups (`wire.aci > 0`) apply.
|
||
///
|
||
/// Direct entities are resolved here from their authored color/layer. Expanded
|
||
/// block wires are different: `BlockCache` has already resolved their effective
|
||
/// ACI through ByBlock and layer-0/ByLayer inheritance. Their wire name points
|
||
/// at the host INSERT rather than the child entity that produced the geometry,
|
||
/// so resolving that INSERT again would destroy the child's effective ACI.
|
||
///
|
||
/// In particular, a true-color INSERT on an indexed-color layer would overwrite
|
||
/// an inherited child ACI with 0 and bypass the CTB entirely.
|
||
fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireModel) {
|
||
let Some(handle) = crate::scene::Scene::handle_from_wire_name(&wire.name) else {
|
||
return;
|
||
};
|
||
let Some(entity) = scene.document.get_entity(handle) else {
|
||
return;
|
||
};
|
||
|
||
// Expanded block geometry already carries the effective ACI calculated by
|
||
// BlockCache. `wire.name` identifies the host INSERT for picking, not the
|
||
// block child whose color inheritance produced `wire.aci`.
|
||
if matches!(entity, codec::EntityType::Insert(_)) {
|
||
return;
|
||
}
|
||
|
||
let common = entity.common();
|
||
wire.aci = match &common.color {
|
||
codec::types::Color::Index(index) => *index,
|
||
codec::types::Color::ByLayer => scene
|
||
.document
|
||
.layers
|
||
.get(&common.layer)
|
||
.map(|layer| match &layer.color {
|
||
codec::types::Color::Index(index) => *index,
|
||
_ => 0,
|
||
})
|
||
.unwrap_or(7),
|
||
_ => 0,
|
||
};
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod plot_owner_aci_tests {
|
||
use super::plot_owner_aci;
|
||
|
||
#[test]
|
||
fn expanded_block_wire_keeps_its_resolved_effective_aci() {
|
||
let mut scene = crate::scene::Scene::new();
|
||
|
||
// Real-world regression case:
|
||
// the INSERT itself has explicit true-color white, while its block
|
||
// children are layer 0 + ByLayer. BlockCache resolves those children
|
||
// through the INSERT's layer and stores that effective ACI on the wire.
|
||
let mut insert = codec::entities::Insert::new("*U264", codec::types::Vector3::ZERO);
|
||
insert.common.color = codec::types::Color::Rgb {
|
||
r: 255,
|
||
g: 255,
|
||
b: 255,
|
||
};
|
||
|
||
let insert_handle = scene
|
||
.document
|
||
.add_entity(codec::EntityType::Insert(insert))
|
||
.expect("insert must be added");
|
||
|
||
let mut wire = crate::scene::WireModel::default();
|
||
wire.name = insert_handle.value().to_string();
|
||
|
||
// Pretend BlockCache resolved the layer-0 child to indexed color 5.
|
||
// The plot preparation step must preserve it instead of replacing it
|
||
// with ACI 0 from the host INSERT's true color.
|
||
wire.aci = 5;
|
||
|
||
plot_owner_aci(&scene, &mut wire);
|
||
|
||
assert_eq!(
|
||
wire.aci, 5,
|
||
"expanded block geometry must keep the effective ACI already resolved by BlockCache"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn direct_entity_still_refreshes_aci_from_its_owner() {
|
||
let mut scene = crate::scene::Scene::new();
|
||
|
||
let mut line = codec::entities::Line::from_points(
|
||
codec::types::Vector3::ZERO,
|
||
codec::types::Vector3::new(1.0, 0.0, 0.0),
|
||
);
|
||
line.common.color = codec::types::Color::Index(1);
|
||
|
||
let line_handle = scene
|
||
.document
|
||
.add_entity(codec::EntityType::Line(line))
|
||
.expect("line must be added");
|
||
|
||
let mut wire = crate::scene::WireModel::default();
|
||
wire.name = line_handle.value().to_string();
|
||
wire.aci = 5;
|
||
|
||
plot_owner_aci(&scene, &mut wire);
|
||
|
||
assert_eq!(
|
||
wire.aci, 1,
|
||
"ordinary entity plot wires must still resolve ACI from their owner"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn plot_scene_content(
|
||
scene: &crate::scene::Scene,
|
||
paper_space_last: bool,
|
||
render_mode_override: Option<codec::entities::ViewportRenderMode>,
|
||
) -> PlotContent {
|
||
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,
|
||
codec::EntityType::Viewport(viewport)
|
||
if !crate::scene::Scene::is_sheet_viewport(
|
||
&scene.document,
|
||
viewport,
|
||
)
|
||
)
|
||
}))
|
||
});
|
||
model_wires.retain(|wire| wire.plot_visible);
|
||
// A text / leader background mask is filled with the canvas colour so it
|
||
// hides what is behind it; on paper that colour is the sheet's, not the
|
||
// dark screen's. Mark such fills so the export paints them paper-white
|
||
// instead of plotting the canvas colour (or turning it black). (#1072)
|
||
let canvas = scene.current_bg();
|
||
let with_depth = |wires: Vec<crate::scene::WireModel>| {
|
||
let depths = scene.plot_wire_depths(&wires);
|
||
wires
|
||
.into_iter()
|
||
.zip(depths)
|
||
.map(|(mut wire, draw_depth)| {
|
||
if !wire.fill_tris.is_empty() && wire.color[..3] == canvas[..3] {
|
||
wire.bg_adapt =
|
||
Some(Box::new(crate::scene::model::wire_model::BgAdaptInputs {
|
||
raw_color: wire.color,
|
||
canvas_color: true,
|
||
..Default::default()
|
||
}));
|
||
}
|
||
plot_owner_aci(scene, &mut wire);
|
||
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();
|
||
let paper_images = scene.paper_plot_images();
|
||
if scene.current_layout == "Model" {
|
||
let splits = crate::io::pdf_export::PlotGroupSplits {
|
||
wires: paper_wires.len(),
|
||
hatches: paper_hatches.len(),
|
||
wipeouts: paper_wipeouts.len(),
|
||
images: paper_images.len(),
|
||
};
|
||
return PlotContent {
|
||
wires: std::sync::Arc::new(paper_wires),
|
||
hatches: paper_hatches,
|
||
wipeouts: paper_wipeouts,
|
||
images: paper_images,
|
||
group_splits: splits,
|
||
};
|
||
}
|
||
let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) =
|
||
scene.viewport_plot_fills();
|
||
model_pattern_wires.retain(|(wire, _)| wire.plot_visible);
|
||
let model_pattern_wires = model_pattern_wires
|
||
.into_iter()
|
||
.map(|(mut wire, draw_depth)| {
|
||
plot_owner_aci(scene, &mut wire);
|
||
crate::io::pdf_export::PlotWire { wire, draw_depth }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
let (wires, hatches, wipeouts, images, 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(),
|
||
images: model_images.len(),
|
||
};
|
||
let mut wires = model_wires;
|
||
let mut hatches = model_hatches;
|
||
let mut wipeouts = model_wipeouts;
|
||
let mut images = model_images;
|
||
wires.extend(model_pattern_wires);
|
||
wires.extend(paper_wires);
|
||
hatches.extend(paper_hatches);
|
||
wipeouts.extend(paper_wipeouts);
|
||
images.extend(paper_images);
|
||
(wires, hatches, wipeouts, images, splits)
|
||
} else {
|
||
let splits = crate::io::pdf_export::PlotGroupSplits {
|
||
wires: paper_wires.len(),
|
||
hatches: paper_hatches.len(),
|
||
wipeouts: paper_wipeouts.len(),
|
||
images: paper_images.len(),
|
||
};
|
||
let mut wires = paper_wires;
|
||
let mut hatches = paper_hatches;
|
||
let mut wipeouts = paper_wipeouts;
|
||
let mut images = paper_images;
|
||
wires.extend(model_wires);
|
||
wires.extend(model_pattern_wires);
|
||
hatches.extend(model_hatches);
|
||
wipeouts.extend(model_wipeouts);
|
||
images.extend(model_images);
|
||
(wires, hatches, wipeouts, images, splits)
|
||
};
|
||
PlotContent {
|
||
wires: std::sync::Arc::new(wires),
|
||
hatches,
|
||
wipeouts,
|
||
images,
|
||
group_splits: splits,
|
||
}
|
||
}
|
||
|
||
impl OpenCADStudio {
|
||
/// Persist exact ACIS bodies, and an edge cache with them, for solids that
|
||
/// have no ACIS data before saving. A solid that has ACIS data keeps its
|
||
/// own wireframe cache: display edges are not written into a solid OCS
|
||
/// did not create or edit (those get their wires when they are built).
|
||
pub(in crate::app) fn sync_solid_models_for_save(&mut self, i: usize) {
|
||
use codec::EntityType;
|
||
let scene = &mut self.tabs[i].scene;
|
||
let targets: Vec<(codec::Handle, bool)> = scene
|
||
.document
|
||
.entities()
|
||
.filter_map(|entity| {
|
||
let EntityType::Solid3D(solid) = entity else {
|
||
return None;
|
||
};
|
||
let h = solid.common.handle;
|
||
(!solid.acis_data.has_data()).then_some((h, solid.wires.is_empty()))
|
||
})
|
||
.collect();
|
||
for (h, needs_wires) in targets {
|
||
let body = scene.solid_models.get(&h);
|
||
let sat = body.and_then(crate::scene::convert::acis_export::solid_to_sat);
|
||
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]);
|
||
codec::entities::Wire::from_points(vec![
|
||
codec::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,
|
||
),
|
||
codec::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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write a header variable on the active drawing and mark it modified.
|
||
pub(in crate::app) fn set_drawing_var(
|
||
&mut self,
|
||
write: impl FnOnce(&mut codec::document::HeaderVariables),
|
||
) {
|
||
let i = self.active_tab;
|
||
if let Some(tab) = self.tabs.get_mut(i) {
|
||
write(&mut tab.scene.document.header);
|
||
tab.dirty = true;
|
||
}
|
||
}
|
||
|
||
/// Re-tessellate active-drawing solids after an ISOLINES change.
|
||
pub(in crate::app) fn regenerate_meshes(&mut self) {
|
||
let i = self.active_tab;
|
||
if let Some(tab) = self.tabs.get_mut(i) {
|
||
tab.scene.populate_meshes_from_document();
|
||
}
|
||
}
|
||
|
||
/// Rebuild text geometry in every open drawing.
|
||
///
|
||
/// `TEXTFILL` is a process-global, so a change to it invalidates the text
|
||
/// in every tab. Doing only the active one leaves the others rendering the
|
||
/// previous setting until some unrelated edit happens to rebuild them.
|
||
pub(in crate::app) fn invalidate_text_everywhere(&mut self) {
|
||
for tab in &mut self.tabs {
|
||
tab.scene.invalidate_text_geometry_dependencies();
|
||
}
|
||
}
|
||
|
||
/// Snapshot the persisted UI preferences from live state.
|
||
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
|
||
crate::app::settings::UserSettings {
|
||
spacemouse: self.spacemouse_preferences,
|
||
dyn_input: self.dyn_input,
|
||
dyn_mode: self.dyn_mode,
|
||
polar: self.polar_mode,
|
||
polar_increment_deg: self.polar_increment_deg,
|
||
zoom_wheel_reversed: self.zoom_wheel_reversed,
|
||
touchpad_pan: self.touchpad_pan,
|
||
ui_scale_percent: self.ui_scale_percent,
|
||
zoom_factor: self.zoom_factor,
|
||
cursor_size: self.cursor_size,
|
||
pick_box: self.pick_box,
|
||
options_tab: self.options_tab,
|
||
show_viewcube: self.show_viewcube,
|
||
show_ucs_icon: self.show_ucs_icon,
|
||
ucs_icon_at_origin: self.ucs_icon_at_origin,
|
||
selection_cycling: self.selection_cycling,
|
||
double_click_block_refedit: self.double_click_block_refedit,
|
||
double_click_block_attedit: self.double_click_block_attedit,
|
||
right_click_mode: self.right_click_mode,
|
||
right_click_hold_ms: self.right_click_hold_ms,
|
||
grip_object_limit: self.grip_object_limit,
|
||
image_frame: crate::scene::frame::profile_image_mode(),
|
||
attdef_on_screen: crate::modules::draw::draw::attdef::session().on_screen,
|
||
field_display: crate::entities::field::display(),
|
||
surface_associativity: crate::scene::model::sweep_model::surface_associativity(),
|
||
ucs_ortho: crate::modules::view::view_cmd::ucs_ortho(),
|
||
ucs_view: crate::modules::view::view_cmd::ucs_view(),
|
||
ncopy_bind: self.ncopy_bind,
|
||
cursor_type: self.cursor_type,
|
||
crosshair_color: self.crosshair_color,
|
||
snap_marker_color: self.snap_marker_color,
|
||
command_text_color: self.command_text_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,
|
||
check_missing_fonts: self.check_missing_fonts,
|
||
font_source_url: self.font_source_url.clone(),
|
||
qnew_template: self.qnew_template.clone(),
|
||
donation_prompt_version: self.donation_prompt_version.clone(),
|
||
gpu_warning_silenced: self.gpu_warning_silenced.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,
|
||
show_constraint_values: self.show_constraint_values,
|
||
auto_constrain: self.auto_constrain_settings.clone(),
|
||
constraint_solve_mode: self.constraint_solve_mode,
|
||
constraint_infer: self.constraint_infer,
|
||
constraint_bar_display: self.constraint_bar_display,
|
||
constraint_bar_mode: self.constraint_bar_mode,
|
||
savetime_min: self.savetime_min,
|
||
script_commands: self.script_commands,
|
||
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,
|
||
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,
|
||
),
|
||
snap_spacing_x: self.snapper.snap_spacing_x,
|
||
snap_spacing_y: self.snapper.snap_spacing_y,
|
||
grid_spacing_x: self.grid_spacing_x,
|
||
grid_spacing_y: self.grid_spacing_y,
|
||
grid_major_every: self.grid_major_every,
|
||
grid_adaptive: self.grid_adaptive,
|
||
grid_beyond_limits: self.grid_beyond_limits,
|
||
block_mru: self.block_mru.clone(),
|
||
block_freq: self.block_freq.clone(),
|
||
block_recent: self.block_palette.recent.clone(),
|
||
block_favorites: self.block_palette.favorites.clone(),
|
||
block_libraries: self.block_palette.libraries.clone(),
|
||
block_palette_view: self.block_palette.view as u8,
|
||
block_insert: self.block_palette.options.clone(),
|
||
block_mru_list: self.block_mru_list,
|
||
block_redefine_mode: self.block_redefine_mode,
|
||
block_navigate: self.block_navigate.clone(),
|
||
count_color: self.count_palette.color,
|
||
count_error_color: self.count_palette.error_color,
|
||
count_service: self.count_palette.service,
|
||
count_invalid_area: self.count_palette.invalid_choice,
|
||
sheet_set: self.sheet_set.settings,
|
||
}
|
||
}
|
||
|
||
/// Apply restored preferences to live state.
|
||
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
|
||
self.dyn_mode = s.dyn_mode;
|
||
self.set_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.touchpad_pan = s.touchpad_pan;
|
||
self.ui_scale_percent = crate::app::settings::clamp_ui_scale(s.ui_scale_percent);
|
||
self.zoom_factor = crate::app::settings::clamp_zoom_factor(s.zoom_factor);
|
||
// Like the drafting-rotation field below, the Options field edits a
|
||
// buffer rather than the value, so it is reseeded whenever the value
|
||
// is restored from behind it — a start-up, or the Options window's
|
||
// own Close putting a change back.
|
||
self.zoom_factor_input = self.zoom_factor.to_string();
|
||
self.cursor_size = s.cursor_size.clamp(1, 100);
|
||
self.pick_box = s.pick_box.clamp(0, 50);
|
||
self.options_tab = s.options_tab;
|
||
self.spacemouse_preferences = s.spacemouse;
|
||
// These four drive real features with commands and status-bar pills,
|
||
// but they lived only on the app struct: turning the ViewCube off and
|
||
// restarting brought it straight back.
|
||
self.show_viewcube = s.show_viewcube;
|
||
self.show_ucs_icon = s.show_ucs_icon;
|
||
self.ucs_icon_at_origin = s.ucs_icon_at_origin;
|
||
self.selection_cycling = s.selection_cycling;
|
||
self.double_click_block_refedit = s.double_click_block_refedit;
|
||
self.double_click_block_attedit = s.double_click_block_attedit;
|
||
self.right_click_mode = s.right_click_mode;
|
||
self.right_click_hold_ms =
|
||
super::super::settings::clamp_right_click_hold_ms(s.right_click_hold_ms);
|
||
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
|
||
crate::scene::frame::set_profile_image_mode(s.image_frame);
|
||
crate::modules::draw::draw::attdef::session().on_screen = s.attdef_on_screen;
|
||
crate::entities::field::set_display(s.field_display);
|
||
crate::scene::model::sweep_model::set_surface_associativity(s.surface_associativity);
|
||
crate::modules::view::view_cmd::set_ucs_ortho(s.ucs_ortho);
|
||
crate::modules::view::view_cmd::set_ucs_view(s.ucs_view);
|
||
self.ncopy_bind = s.ncopy_bind;
|
||
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.snap_marker_color = s.snap_marker_color;
|
||
self.snap_marker_color_input = s
|
||
.snap_marker_color
|
||
.map(crate::app::config::rgb_to_hex)
|
||
.unwrap_or_default();
|
||
self.command_text_color = s.command_text_color;
|
||
self.command_text_color_input = s
|
||
.command_text_color
|
||
.map(crate::app::config::rgb_to_hex)
|
||
.unwrap_or_default();
|
||
crate::ui::command_line::set_text_color(s.command_text_color);
|
||
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
|
||
};
|
||
// The Options field edits a buffer rather than the value, so it has to
|
||
// be reseeded whenever the value is restored from behind it.
|
||
self.snap_angle_input = crate::app::settings::format_snap_angle(self.snap_angle_deg);
|
||
// Ortho + running OSNAP are per-drawing (adopted from the header on
|
||
// open / tab switch), not app-global, so they are not applied here.
|
||
self.snapper.otrack_enabled = s.otrack;
|
||
self.default_assoc_prompted = s.default_assoc_prompted;
|
||
self.check_missing_fonts = s.check_missing_fonts;
|
||
self.font_source_url = s.font_source_url.clone();
|
||
self.qnew_template = s.qnew_template.clone();
|
||
self.font_source_input = s.font_source_url.clone();
|
||
self.donation_prompt_version = s.donation_prompt_version.clone();
|
||
self.gpu_warning_silenced = s.gpu_warning_silenced.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.show_constraint_values = s.show_constraint_values;
|
||
self.auto_constrain_settings = s.auto_constrain.clone();
|
||
self.auto_constrain_settings.sanitize();
|
||
self.auto_constrain_distance_input =
|
||
format!("{}", self.auto_constrain_settings.distance_tolerance);
|
||
self.auto_constrain_angle_input =
|
||
format!("{}", self.auto_constrain_settings.angle_tolerance_deg);
|
||
self.constraint_solve_mode = s.constraint_solve_mode;
|
||
self.constraint_infer = s.constraint_infer;
|
||
self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3);
|
||
self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095);
|
||
self.savetime_min = s.savetime_min;
|
||
self.script_commands = s.script_commands;
|
||
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;
|
||
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);
|
||
self.snapper.snap_spacing_x = crate::app::settings::sanitize_snap_spacing(s.snap_spacing_x);
|
||
self.snapper.snap_spacing_y = crate::app::settings::sanitize_snap_spacing(s.snap_spacing_y);
|
||
self.grid_spacing_x = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_x);
|
||
self.grid_spacing_y = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_y);
|
||
self.grid_major_every = crate::app::settings::sanitize_grid_major(s.grid_major_every);
|
||
self.grid_adaptive = s.grid_adaptive;
|
||
self.grid_beyond_limits = s.grid_beyond_limits;
|
||
// 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();
|
||
self.block_mru_list = s.block_mru_list.min(100);
|
||
self.block_redefine_mode = s.block_redefine_mode.min(2);
|
||
self.block_navigate = s.block_navigate.clone();
|
||
self.count_palette.color = s.count_color;
|
||
self.count_palette.error_color = s.count_error_color;
|
||
self.count_palette.service = s.count_service;
|
||
self.count_palette.invalid_choice = s.count_invalid_area.min(2);
|
||
self.sheet_set.settings = s.sheet_set;
|
||
self.block_palette.recent = s.block_recent.clone();
|
||
self.block_palette.favorites = s.block_favorites.clone();
|
||
self.block_palette.libraries = s.block_libraries.clone();
|
||
self.block_palette.view =
|
||
crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view);
|
||
self.block_palette.set_options(s.block_insert.clone());
|
||
self.trim_recent_blocks();
|
||
// 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) {
|
||
self.insname = name.to_string();
|
||
self.note_recent_block(name);
|
||
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
|
||
},
|
||
show_graphic_attributes: self.show_graphic_attributes,
|
||
graphic_fills_on_current_layer: self.graphic_attributes.fill_on_current_layer,
|
||
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();
|
||
// A saved `System` scheme re-resolves at boot: the stored palette is
|
||
// whatever the last resolution produced, so refresh it alongside the
|
||
// live theme (COLORSCHEME SYSTEM documents this boot-time read).
|
||
if self.ui_theme.name.eq_ignore_ascii_case("System") {
|
||
self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(
|
||
crate::app::config::resolve_system_theme().seed(),
|
||
);
|
||
}
|
||
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.show_graphic_attributes = cfg.show_graphic_attributes;
|
||
self.graphic_attributes.fill_on_current_layer = cfg.graphic_fills_on_current_layer;
|
||
if self.show_graphic_attributes {
|
||
self.dock.dock_below_properties(crate::ui::dock::PanelId::GraphicAttributes);
|
||
}
|
||
self.ribbon
|
||
.set_graphic_attributes(self.show_graphic_attributes);
|
||
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());
|
||
self.shortcut_bindings
|
||
.entry("SHIFT+TAB".to_string())
|
||
.or_insert_with(|| "DYNTABPREV".to_string());
|
||
crate::app::shortcuts::sync_space_dispatch_flag(&self.shortcut_bindings);
|
||
}
|
||
|
||
/// 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.
|
||
// Web guard: a second open (Ctrl+O, the OPEN command, host
|
||
// automation) while a drop/restore/picker open is still in flight
|
||
// would overwrite `self.opening` — the in-flight open's
|
||
// `WebFileOpened` then misses the id check and its bytes are lost,
|
||
// and any pending restore prompt stalls for the session. Native
|
||
// keeps the old behaviour: its `OpenPathPicked` round-trip is
|
||
// guarded downstream.
|
||
#[cfg(target_arch = "wasm32")]
|
||
if self.opening.is_some() {
|
||
self.command_line
|
||
.push_info(crate::tf!("An open is already in progress.").as_ref());
|
||
return Task::none();
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
// The browser picker + parse happen inside `pick_and_load_web`;
|
||
// the real name is unknown until then, so show a generic label
|
||
// meanwhile.
|
||
let (open_id, state) = self.begin_web_open("Opening…".into(), 0);
|
||
Task::perform(crate::io::pick_and_load_web(state), move |outcome| {
|
||
Message::WebFileOpened(open_id, outcome)
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Web: open a drawing from bytes the caller already holds, through the
|
||
/// same path as a file chosen in the browser picker.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(in crate::app) fn open_web_bytes(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
|
||
let (open_id, state) = self.begin_web_open(name.clone(), bytes.len() as u64);
|
||
Task::perform(
|
||
crate::io::open_bytes_web(name, std::sync::Arc::from(bytes), state),
|
||
move |outcome| Message::WebFileOpened(open_id, outcome),
|
||
)
|
||
}
|
||
|
||
/// `FileOpened` only installs the result when an open is in progress, so
|
||
/// mark one.
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn begin_web_open(
|
||
&mut self,
|
||
name: String,
|
||
size_bytes: u64,
|
||
) -> (u64, std::sync::Arc<crate::io::OpenProgressState>) {
|
||
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,
|
||
source_path: None,
|
||
size_bytes,
|
||
state: state.clone(),
|
||
started: Instant::now(),
|
||
recovery_error: None,
|
||
recovery_read_stats: None,
|
||
recovery_bytes: None,
|
||
});
|
||
(open_id, state)
|
||
}
|
||
|
||
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 version = self.tabs[i].scene.document.version;
|
||
self.save_tab_synchronously_protected_as(i, path, version, set_current_path)
|
||
}
|
||
|
||
/// Synchronous protected save with an explicit output version. Automation
|
||
/// uses this path so format conversion never depends on an implicit source
|
||
/// version or silently upgrades to the newest DWG.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(in crate::app) fn save_tab_synchronously_protected_as(
|
||
&mut self,
|
||
i: usize,
|
||
path: std::path::PathBuf,
|
||
version: codec::DxfVersion,
|
||
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 snapshot = self.tabs[i].scene.document_for_save();
|
||
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.tabs[i].scene.document.version = version;
|
||
}
|
||
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(())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drop Plot-dialog state that belongs to the previously active drawing.
|
||
///
|
||
/// Plot settings such as paper size and CTB are document/layout data.
|
||
/// A newly opened DWG may reuse the same tab index and layout name as the
|
||
/// startup drawing, so `(tab, layout)` alone is not enough to identify the
|
||
/// context. Keeping that cache makes the first Plot dialog show the startup
|
||
/// A4/no-CTB state; the second opening then appears correct only because the
|
||
/// first one refreshed the runtime state.
|
||
fn reset_plot_session_for_opened_document(&mut self) {
|
||
self.plot_prev = None;
|
||
self.plot_prev_context = None;
|
||
self.plot_reconcile_printer_media = None;
|
||
self.plot_setup_template = None;
|
||
self.plot_window = None;
|
||
}
|
||
|
||
pub(super) fn on_file_opened(
|
||
&mut self,
|
||
name: String,
|
||
path: std::path::PathBuf,
|
||
doc: codec::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();
|
||
}
|
||
// Web drawings live under bare file names: two tabs sharing a name
|
||
// also share the single browser-stored copy, and the last save
|
||
// silently replaces the other tab's. Warn at open time — the
|
||
// desktop identifies drawings by full path and needs no such check.
|
||
#[cfg(target_arch = "wasm32")]
|
||
if self.tab_showing(&path).is_some() {
|
||
self.command_line.push_warning(
|
||
crate::tf!(
|
||
"\"{}\" is already open in another tab. On the web both \
|
||
tabs share one stored copy — the last save overwrites \
|
||
the other.",
|
||
name
|
||
)
|
||
.as_ref(),
|
||
);
|
||
}
|
||
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 == codec::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(),
|
||
);
|
||
}
|
||
// Web: NotFound xrefs are summarized in one warning after the
|
||
// loop (see below) — the per-reference line carries a
|
||
// filesystem path no browser can open, and a drawing with
|
||
// several unresolved xrefs would repeat the same hint.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
crate::io::xref::XrefStatus::NotFound => {
|
||
self.command_line.push_error(
|
||
crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(),
|
||
);
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
crate::io::xref::XrefStatus::NotFound => {}
|
||
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(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Web: one warning per open lists every unresolved xref and the way
|
||
// to fix it — dropping the files on the page registers their bytes,
|
||
// and reopening the drawing (or XRELOAD) then resolves them.
|
||
#[cfg(target_arch = "wasm32")]
|
||
self.warn_unresolved_xrefs(&caches.xrefs);
|
||
#[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
|
||
};
|
||
// A Restore-all batch's LAST restore inserts its tab here — one
|
||
// async round AFTER its settle message. The focus flag therefore
|
||
// also fires here: the message-boundary hook alone could run
|
||
// before this insertion and be overwritten by it.
|
||
#[cfg(target_arch = "wasm32")]
|
||
if self.web_session_focus_pending && self.web_autosave_restore_all_remaining == 0 {
|
||
self.web_session_focus_pending = false;
|
||
let _ = self.select_remembered_session_tab();
|
||
}
|
||
|
||
// Missing-on-open: neutral command-line notice plus a flag
|
||
// the Reference Manager reads. Never an auto-open modal.
|
||
let missing = caches
|
||
.xrefs
|
||
.iter()
|
||
.filter(|item| item.status == crate::io::xref::XrefStatus::NotFound)
|
||
.count();
|
||
self.tabs[i].xref_missing = missing;
|
||
if missing > 0 {
|
||
self.command_line.push_output(
|
||
crate::tf!(
|
||
"{} reference(s) not found — open the reference manager with EXTERNALREFERENCES.",
|
||
missing
|
||
)
|
||
.as_ref(),
|
||
);
|
||
}
|
||
|
||
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].scene.bump_layout_epoch();
|
||
self.tabs[i].scene.bump_scale_epoch();
|
||
self.tabs[i].scene.bump_ucs_epoch();
|
||
// Load parameters first so imported dimensional constraints
|
||
// can resolve their named driving values.
|
||
self.tabs[i].scene.load_named_parameters_from_document();
|
||
self.tabs[i]
|
||
.scene
|
||
.load_parametric_constraints_from_document();
|
||
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);
|
||
// Copies made by earlier releases kept their source's dimension
|
||
// association; such a file is reported as damaged elsewhere.
|
||
crate::scene::dimension_assoc::drop_stray_dimension_reactors(
|
||
&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();
|
||
}
|
||
|
||
// The tab may have been reused for the newly opened file. Any Plot
|
||
// dialog cache belongs to the previous/startup drawing and must not
|
||
// win over this DWG's embedded layout settings on the first Ctrl+P.
|
||
self.reset_plot_session_for_opened_document();
|
||
|
||
// 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().view.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();
|
||
// The tab list just changed (and the active tab with it): remember
|
||
// it for the next boot's restore flow (web session memory).
|
||
#[cfg(target_arch = "wasm32")]
|
||
self.persist_web_session();
|
||
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 {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
// A drawing referencing fonts this machine lacks offers to
|
||
// fetch them from the community repository before the user
|
||
// studies garbled substitute text (unless recovery already
|
||
// owns the modal slot).
|
||
let mut missing =
|
||
crate::io::font_repo::missing_shx_fonts(&self.tabs[i].scene.document);
|
||
missing.retain(|name| {
|
||
!self
|
||
.suppressed_missing_fonts
|
||
.contains(&crate::io::font_repo::font_key(name))
|
||
});
|
||
// Read-only/MCP evaluation sessions must remain non-blocking:
|
||
// report missing fonts through control state, but never offer
|
||
// a network/download mutation from a read-only launch.
|
||
if !missing.is_empty() && self.check_missing_fonts && !self.read_only {
|
||
self.font_source_input = self.font_source_url.clone();
|
||
self.missing_fonts = Some(missing);
|
||
self.missing_fonts_path = self.tabs[i].current_path.clone();
|
||
self.missing_fonts_downloading = false;
|
||
self.active_modal = Some(crate::app::ModalKind::MissingFonts);
|
||
}
|
||
}
|
||
self.drain_pending_open()
|
||
};
|
||
let startup_script_task = if !self.pending_startup_script_lines.is_empty() {
|
||
let lines = std::mem::take(&mut self.pending_startup_script_lines);
|
||
Task::batch(
|
||
lines
|
||
.into_iter()
|
||
.map(|l| Task::done(Message::ScriptLine(l))),
|
||
)
|
||
} else {
|
||
Task::none()
|
||
};
|
||
let sheet_set_task = self.sheet_set_after_open(i);
|
||
Task::batch([
|
||
thumbs_task,
|
||
pending_open_task,
|
||
interaction_task,
|
||
startup_script_task,
|
||
sheet_set_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_for_save();
|
||
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
||
// Native: extraction and the file write both run on a background
|
||
// thread, unchanged.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let worker_name = block_name.clone();
|
||
let worker_path = path.clone();
|
||
background_task(
|
||
crate::t!("WBLOCK save"),
|
||
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),
|
||
)
|
||
}
|
||
// Web: extraction is pure document manipulation (it reads the clone
|
||
// above and builds a fresh document) — run it inline on the main
|
||
// thread as the prepare step, together with the shared save
|
||
// preparation, then hand the DWG/DXF writer pass to the serializer
|
||
// worker the way the web Save flow does
|
||
// (`serialize_prepared_document` falls back inline when the worker
|
||
// cannot help). Completion (`WebWblockSerialized`) delivers the
|
||
// browser download plus the same `WblockWriteFinished` command-line
|
||
// bookkeeping.
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let extracted = if block_name == "*" {
|
||
crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles)
|
||
} else {
|
||
crate::modules::insert::wblock::extract_block_to_doc(&document, &block_name)
|
||
}
|
||
.map_err(|e| e.to_string());
|
||
let document = match extracted {
|
||
Ok(document) => document,
|
||
// Same reporting the background path used for a failed
|
||
// extraction — straight through the existing message.
|
||
Err(error) => {
|
||
return Task::done(Message::WblockWriteFinished(
|
||
block_name,
|
||
path,
|
||
Err(error),
|
||
))
|
||
}
|
||
};
|
||
let ext = path
|
||
.extension()
|
||
.map(|ext| ext.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "dwg".to_string());
|
||
let filename = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "block.dwg".into());
|
||
let version = document.version;
|
||
let prepared = crate::io::prepare_document_for_save(document, version);
|
||
Task::perform(
|
||
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
|
||
move |result| Message::WebWblockSerialized {
|
||
block_name,
|
||
path,
|
||
filename,
|
||
result,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Web SAVEALL: every dirty tab that already has a name-only path is
|
||
/// serialized and downloaded in one pass — the worker-routed counterpart
|
||
/// of a single web Save. Each tab is prepared (state sync + clone +
|
||
/// style/dimension bake) once up front on the main thread, then the
|
||
/// DWG/DXF writer passes run serially on the serializer worker — the
|
||
/// same single-future structure the autosave round uses. The browser may
|
||
/// ask once for permission to download several files, which is the
|
||
/// platform's price. Clean tabs are skipped (re-downloading identical
|
||
/// bytes is noise); tabs with no path still need SAVEAS. Completion
|
||
/// (`on_web_saveall_serialized`) fires the downloads and clears each
|
||
/// tab's dirty flag — but only if the tab did not change while its
|
||
/// writer ran.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(in crate::app) fn saveall_web(&mut self) -> Task<Message> {
|
||
let mut skipped = 0usize;
|
||
let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new();
|
||
for t in 0..self.tabs.len() {
|
||
if self.tabs[t].is_start {
|
||
continue;
|
||
}
|
||
let Some(path) = self.tabs[t].current_path.clone() else {
|
||
skipped += 1;
|
||
continue;
|
||
};
|
||
if !self.tabs[t].dirty {
|
||
continue;
|
||
}
|
||
// The same preparation a single web Save makes on the tab.
|
||
self.sync_view_state_for_save(t);
|
||
sync_annotation_scale_header(&mut self.tabs[t].scene);
|
||
self.stamp_header_sysvars(t);
|
||
self.sync_solid_models_for_save(t);
|
||
self.tabs[t].scene.sync_native_parametric_graph();
|
||
let ext = path
|
||
.extension()
|
||
.map(|ext| ext.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "dwg".to_string());
|
||
let version = self.tabs[t].scene.document.version;
|
||
let filename = path
|
||
.file_name()
|
||
.map(|name| name.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||
// Race guard (the same snapshot a single web Save takes): the
|
||
// markers are read after the state syncs, so completion only
|
||
// clears the dirty flag if the tab did not change while the
|
||
// writer ran off-thread.
|
||
let tab = &self.tabs[t];
|
||
let entry = crate::app::WebSaveallEntry {
|
||
tab_id: tab.id,
|
||
path,
|
||
filename,
|
||
ext,
|
||
version,
|
||
geometry_epoch: tab.scene.geometry_epoch,
|
||
edit_revision: tab.edit_revision,
|
||
camera_generation: tab.scene.camera_generation,
|
||
// Placeholder — the serialization future below overwrites
|
||
// it before the completion message is built.
|
||
result: Ok(Vec::new()),
|
||
};
|
||
pending.push((
|
||
entry,
|
||
crate::io::prepare_document_for_save(tab.scene.document_for_save(), version),
|
||
));
|
||
}
|
||
if pending.is_empty() {
|
||
self.report_saveall_web(0, skipped);
|
||
return Task::none();
|
||
}
|
||
Task::perform(
|
||
async move {
|
||
let mut results = Vec::with_capacity(pending.len());
|
||
for (mut entry, prepared) in pending {
|
||
entry.result =
|
||
crate::io::serialize_prepared_document(&prepared, &entry.ext).await;
|
||
results.push(entry);
|
||
}
|
||
results
|
||
},
|
||
move |results| Message::WebSaveallSerialized { skipped, results },
|
||
)
|
||
}
|
||
|
||
/// Web SAVEALL completion: the serializer worker (or inline fallback)
|
||
/// produced each tab's bytes. The download is the only artifact of a
|
||
/// tab's save — fire it even if the tab was closed while serialization
|
||
/// was in flight. Bookkeeping matches the old synchronous pass (version
|
||
/// stamped, recovery flag cleared, dirty cleared under the race guard,
|
||
/// one error line per failed tab), then the shared summary line.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_saveall_serialized(
|
||
&mut self,
|
||
skipped: usize,
|
||
results: Vec<crate::app::WebSaveallEntry>,
|
||
) -> Task<Message> {
|
||
let mut saved = 0usize;
|
||
for entry in results {
|
||
match entry.result {
|
||
Ok(bytes) => {
|
||
crate::sys::download_bytes(&entry.filename, &bytes);
|
||
saved += 1;
|
||
let Some(i) = self.tabs.iter().position(|tab| tab.id == entry.tab_id) else {
|
||
continue;
|
||
};
|
||
self.tabs[i].scene.document.version = entry.version;
|
||
self.tabs[i].recovery_save_as_required = false;
|
||
let snapshot_is_current = self.tabs[i].scene.geometry_epoch
|
||
== entry.geometry_epoch
|
||
&& self.tabs[i].edit_revision == entry.edit_revision
|
||
&& self.tabs[i].scene.camera_generation == entry.camera_generation;
|
||
if snapshot_is_current {
|
||
self.tabs[i].dirty = false;
|
||
}
|
||
}
|
||
Err(e) => self.command_line.push_error(
|
||
crate::tf!("SAVEALL: {} failed: {e}", entry.path.display()).as_ref(),
|
||
),
|
||
}
|
||
}
|
||
self.report_saveall_web(saved, skipped);
|
||
Task::none()
|
||
}
|
||
|
||
/// The SAVEALL summary line — shared by the nothing-queued early return
|
||
/// and the worker completion.
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn report_saveall_web(&mut self, saved: usize, skipped: usize) {
|
||
self.command_line.push_output(
|
||
crate::tf!(
|
||
"SAVEALL: saved {saved} drawing(s){}.",
|
||
if skipped > 0 {
|
||
crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned()
|
||
} else {
|
||
String::new()
|
||
}
|
||
)
|
||
.as_ref(),
|
||
);
|
||
}
|
||
|
||
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(
|
||
crate::t!("STL export"),
|
||
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())?;
|
||
write_export(&worker_path, &bytes)
|
||
},
|
||
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(
|
||
crate::t!("STEP export"),
|
||
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())?;
|
||
write_export(&worker_path, text.as_bytes())
|
||
},
|
||
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(
|
||
crate::t!("OBJ import"),
|
||
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),
|
||
)
|
||
}
|
||
|
||
pub(in crate::app) 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) {
|
||
// A save is a field evaluation event (FIELDEVAL bit 2).
|
||
self.tabs[i].scene.update_fields(2, None);
|
||
self.prepare_snapshot_save(i);
|
||
}
|
||
|
||
/// Bring the drawing state the file stores up to date, without the save
|
||
/// event an autosave does not raise.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(in crate::app) fn prepare_snapshot_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);
|
||
self.tabs[i].scene.sync_native_parametric_graph();
|
||
self.stamp_sheet_set_data(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: codec::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 {
|
||
// The tab closed while its capture was queued: the save is over
|
||
// (no serialization follows), so release the in-flight slot.
|
||
crate::io::fs_access::end_web_save(tab_id);
|
||
return Task::none();
|
||
};
|
||
let preview = screenshot.as_ref().and_then(|screenshot| {
|
||
bounds.and_then(|bounds| {
|
||
crate::io::thumbnail::from_screenshot(
|
||
screenshot,
|
||
bounds,
|
||
version >= codec::DxfVersion::AC1027,
|
||
)
|
||
})
|
||
});
|
||
if let Some(preview) = preview {
|
||
self.tabs[i].scene.document.preview = Some(preview);
|
||
}
|
||
|
||
// The name the autosave snapshot for this tab is keyed by — capture it
|
||
// before the save re-points `current_path` at the download name.
|
||
let autosave_key = Self::web_autosave_key(&self.tabs[i]);
|
||
// Race guard (the web counterpart of native's `snapshot_is_current`):
|
||
// completion only clears the dirty flag if the tab did not change
|
||
// while the writer ran off-thread.
|
||
let geometry_epoch = self.tabs[i].scene.geometry_epoch;
|
||
let edit_revision = self.tabs[i].edit_revision;
|
||
let camera_generation = self.tabs[i].scene.camera_generation;
|
||
// Prepare the document here (clone + style sync + dimension bake — the
|
||
// app-side half of the pipeline), then hand the DWG/DXF writer pass to
|
||
// the browser worker so the UI thread stays responsive on large
|
||
// drawings. Completion (download + bookkeeping) continues in
|
||
// `on_web_save_serialized`.
|
||
let prepared =
|
||
crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version);
|
||
Task::perform(
|
||
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
|
||
move |result| Message::WebSaveSerialized {
|
||
tab_id,
|
||
filename,
|
||
autosave_key,
|
||
version,
|
||
geometry_epoch,
|
||
edit_revision,
|
||
camera_generation,
|
||
result,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// Web SAVE completion: the serializer worker (or inline fallback) has the
|
||
/// drawing bytes. When the tab still exists AND holds a File System
|
||
/// Access handle picked for the format these bytes were serialized as,
|
||
/// they go to it in place (createWritable needs no user gesture, so the
|
||
/// write may happen long after the pick); completion continues in
|
||
/// `on_web_save_written`. Anything else takes the legacy download — the
|
||
/// only artifact of the save, launched even if the tab was closed while
|
||
/// serialization was in flight.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_save_serialized(
|
||
&mut self,
|
||
tab_id: u64,
|
||
filename: String,
|
||
autosave_key: String,
|
||
version: codec::DxfVersion,
|
||
geometry_epoch: u64,
|
||
edit_revision: u64,
|
||
camera_generation: u64,
|
||
result: Result<Vec<u8>, String>,
|
||
) -> Task<Message> {
|
||
let bytes = match result {
|
||
Ok(bytes) => bytes,
|
||
Err(error) => {
|
||
self.command_line
|
||
.push_error(crate::tf!("Save failed: {error}").as_ref());
|
||
crate::io::fs_access::end_web_save(tab_id);
|
||
return self.finish_web_save(false);
|
||
}
|
||
};
|
||
// In-place write eligibility, all three required:
|
||
// * the tab still exists — a tab that closed while serialization ran
|
||
// means the user declined this save (discard/close), and the
|
||
// legacy contract for closed tabs is download-only;
|
||
// * a handle is held AND it was picked for the format these bytes
|
||
// were serialized for (the pending ext recorded at capture time):
|
||
// a Save As whose re-pick failed falls back to the download, and
|
||
// the stale handle from the previous format must not receive the
|
||
// fallback bytes (a .dwg file must never silently hold DXF).
|
||
let tab_exists = self.tabs.iter().position(|tab| tab.id == tab_id).is_some();
|
||
let target_ext = crate::io::fs_access::take_pending_save_ext(tab_id);
|
||
let write_in_place = tab_exists
|
||
&& crate::io::fs_access::write_or_download(
|
||
crate::io::fs_access::handle_ext(tab_id).as_deref(),
|
||
target_ext.as_deref(),
|
||
);
|
||
if !write_in_place {
|
||
// Whatever handle is held is stale for these bytes (wrong
|
||
// format, unverifiable format, or a tab that declined the
|
||
// save): drop it so the next save re-picks instead of hitting
|
||
// the same mismatch. No handle held: the drop is a no-op.
|
||
crate::io::fs_access::drop_handle(tab_id);
|
||
} else if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) {
|
||
// The bookkeeping runs at write completion (WebSaveWritten) so a
|
||
// failed write cannot clear the dirty flag; the bytes ride back
|
||
// with the completion message so the OPFS recent store gets the
|
||
// same copy the download path uses.
|
||
let download_name = picked_name.clone();
|
||
return Task::perform(
|
||
async move {
|
||
let result = if let Err(error) =
|
||
crate::io::fs_access::write_handle(tab_id, &bytes).await
|
||
{
|
||
// Keep the save alive: the drawing still lands
|
||
// somewhere via the legacy download (which needs no
|
||
// activation), and the error travels with the
|
||
// completion message for the command-line note.
|
||
crate::sys::download_bytes(&download_name, &bytes);
|
||
Err(error)
|
||
} else {
|
||
Ok(())
|
||
};
|
||
(result, bytes)
|
||
},
|
||
move |(result, bytes)| Message::WebSaveWritten {
|
||
tab_id,
|
||
filename: picked_name,
|
||
autosave_key,
|
||
version,
|
||
geometry_epoch,
|
||
edit_revision,
|
||
camera_generation,
|
||
result,
|
||
bytes,
|
||
},
|
||
);
|
||
}
|
||
// The download is the only artifact of the save — fire it even if the
|
||
// tab was closed while serialization was in flight.
|
||
crate::sys::download_bytes(&filename, &bytes);
|
||
self.finalize_web_save(
|
||
tab_id,
|
||
filename,
|
||
autosave_key,
|
||
version,
|
||
geometry_epoch,
|
||
edit_revision,
|
||
camera_generation,
|
||
bytes,
|
||
)
|
||
}
|
||
|
||
/// Web SAVE in-place completion: the retained handle's write settled.
|
||
/// Success keeps the handle for the next plain Save; failure drops it
|
||
/// (the task already downloaded a copy — note that in the command line,
|
||
/// and re-pick on the next save instead of failing the same way again).
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_save_written(
|
||
&mut self,
|
||
tab_id: u64,
|
||
filename: String,
|
||
autosave_key: String,
|
||
version: codec::DxfVersion,
|
||
geometry_epoch: u64,
|
||
edit_revision: u64,
|
||
camera_generation: u64,
|
||
result: Result<(), String>,
|
||
bytes: Vec<u8>,
|
||
) -> Task<Message> {
|
||
if let Err(error) = result {
|
||
crate::io::fs_access::drop_handle(tab_id);
|
||
self.command_line.push_error(
|
||
crate::tf!(
|
||
"In-place save failed ({error}); a copy was downloaded instead"
|
||
)
|
||
.as_ref(),
|
||
);
|
||
}
|
||
self.finalize_web_save(
|
||
tab_id,
|
||
filename,
|
||
autosave_key,
|
||
version,
|
||
geometry_epoch,
|
||
edit_revision,
|
||
camera_generation,
|
||
bytes,
|
||
)
|
||
}
|
||
|
||
/// Shared completion half of the web save — download and in-place write
|
||
/// alike: recent-copy store task, tab bookkeeping under the race guard
|
||
/// (the dirty flag only clears when the persisted snapshot is still the
|
||
/// tab's current state), autosave-key retirement and the "Saved" line.
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn finalize_web_save(
|
||
&mut self,
|
||
tab_id: u64,
|
||
filename: String,
|
||
autosave_key: String,
|
||
version: codec::DxfVersion,
|
||
geometry_epoch: u64,
|
||
edit_revision: u64,
|
||
camera_generation: u64,
|
||
bytes: Vec<u8>,
|
||
) -> Task<Message> {
|
||
// The save settled (download or in-place write): release the
|
||
// in-flight slot the on_save_file guard refuses against.
|
||
crate::io::fs_access::end_web_save(tab_id);
|
||
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);
|
||
let mut recent_task = Task::perform(
|
||
async move {
|
||
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
|
||
.await
|
||
.map(|_| path)
|
||
},
|
||
Message::WebRecentStored,
|
||
);
|
||
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
|
||
return recent_task;
|
||
};
|
||
self.tabs[i].current_path = Some(
|
||
std::path::Path::new(&filename)
|
||
.file_name()
|
||
.map(|name| std::path::PathBuf::from(name.to_string_lossy().into_owned()))
|
||
.unwrap_or_else(|| std::path::PathBuf::from(filename.clone())),
|
||
);
|
||
self.tabs[i].scene.document.version = version;
|
||
self.tabs[i].recovery_save_as_required = false;
|
||
// A save may have renamed the tab (Save As): refresh the remembered
|
||
// session list (web session memory).
|
||
self.persist_web_session();
|
||
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
|
||
&& self.tabs[i].edit_revision == edit_revision
|
||
&& self.tabs[i].scene.camera_generation == camera_generation;
|
||
if snapshot_is_current {
|
||
self.tabs[i].dirty = false;
|
||
}
|
||
// A clean save supersedes any crash-recovery snapshot for this
|
||
// drawing — drop the old key and the new one so stale copies don't
|
||
// resurface in the next boot scan. But an autosave round may still be
|
||
// writing the same key: its write would land AFTER the remove and
|
||
// resurrect the snapshot (with a newer mtime, so the next scan would
|
||
// offer it as recoverable). Defer the removes to the end of the round.
|
||
let new_key = self.tabs[i]
|
||
.current_path
|
||
.as_ref()
|
||
.map(|path| path.to_string_lossy().into_owned())
|
||
.unwrap_or_default();
|
||
if self.web_autosave_in_flight {
|
||
self.web_autosave_clear_after.push(autosave_key);
|
||
if !new_key.is_empty() {
|
||
self.web_autosave_clear_after.push(new_key);
|
||
}
|
||
} else {
|
||
recent_task = Task::batch([
|
||
recent_task,
|
||
Task::perform(
|
||
async move {
|
||
let _ = crate::io::web_autosave::remove(&autosave_key).await;
|
||
let _ = crate::io::web_autosave::remove(&new_key).await;
|
||
},
|
||
|_| Message::Noop,
|
||
),
|
||
]);
|
||
}
|
||
self.command_line
|
||
.push_output(crate::tf!("Saved: {filename}").as_ref());
|
||
Task::batch([recent_task, self.finish_web_save(true)])
|
||
}
|
||
|
||
/// Tail of the web save flow: continue a pending close when the save
|
||
/// succeeded, or re-open the unsaved-changes dialog when it failed.
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn finish_web_save(&mut self, saved: bool) -> Task<Message> {
|
||
if self.save_dialog_for_unsaved {
|
||
if saved {
|
||
// Only a Tab pending is consumed here; a Quit pending must
|
||
// survive to continue the quit after this save.
|
||
if let Some(crate::app::PendingClose::Tab(tab_id)) = self
|
||
.pending_close
|
||
.take_if(|pending| matches!(pending, crate::app::PendingClose::Tab(_)))
|
||
{
|
||
let continuation = self.update(Message::TabClose(tab_id));
|
||
let rest = self.continue_tab_close_queue();
|
||
return Task::batch([continuation, rest]);
|
||
}
|
||
} else if matches!(self.pending_close, Some(crate::app::PendingClose::Quit)) {
|
||
// Mirror the native Quit continuation: offer to save the
|
||
// next dirty tab, or finish the quit.
|
||
if self.tabs.iter().any(|tab| tab.dirty) {
|
||
return self.open_unsaved_dialog_window();
|
||
}
|
||
return Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]);
|
||
} else if self.pending_close.is_some() {
|
||
return self.open_unsaved_dialog_window();
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(in crate::app) fn queue_native_save(
|
||
&mut self,
|
||
i: usize,
|
||
path: std::path::PathBuf,
|
||
version: codec::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 >= codec::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 mut snapshot = self.tabs[i].scene.document_for_save();
|
||
// First save: references attached with a relative path type while
|
||
// the drawing had no file become relative to it.
|
||
if self.tabs[i].current_path.is_none() && purpose != crate::app::SavePurpose::Autosave {
|
||
crate::io::xref::make_relative(
|
||
&mut snapshot,
|
||
&self.tabs[i].xref_relative_on_save,
|
||
&path,
|
||
);
|
||
}
|
||
// Save-As across folders: rebase relative reference paths onto the
|
||
// new base dir inside the snapshot only (live strings are untouched).
|
||
if purpose == crate::app::SavePurpose::SaveAs {
|
||
let old_base = self.tabs[i]
|
||
.current_path
|
||
.as_ref()
|
||
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
|
||
let new_base = path.parent().map(|p| p.to_path_buf());
|
||
if let (Some(old), Some(new)) = (old_base, new_base) {
|
||
if old != new {
|
||
crate::io::xref::rebase_relative_paths_for_save_as(&mut snapshot, &old, &new);
|
||
}
|
||
}
|
||
}
|
||
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(self.tabs[i].id));
|
||
}
|
||
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(self.tabs[i].id));
|
||
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 {
|
||
let old_path = self.tabs[i].current_path.clone();
|
||
if old_path.is_none() {
|
||
let pending = std::mem::take(&mut self.tabs[i].xref_relative_on_save);
|
||
crate::io::xref::make_relative(
|
||
&mut self.tabs[i].scene.document,
|
||
&pending,
|
||
&outcome.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;
|
||
// Save-As across folders: the save snapshot was rebased
|
||
// in `queue_native_save`; rebase the live document too
|
||
// so the running session agrees with the file just
|
||
// written. Saves are not undoable, so no undo snapshot
|
||
// is pushed.
|
||
if let Some(old) = old_path.as_deref() {
|
||
let old_base = old.parent().map(|p| p.to_path_buf());
|
||
let new_base = outcome.path.parent().map(|p| p.to_path_buf());
|
||
if let (Some(old), Some(new)) = (old_base, new_base) {
|
||
if old != new {
|
||
crate::io::xref::rebase_relative_paths_for_save_as(
|
||
&mut self.tabs[i].scene.document,
|
||
&old,
|
||
&new,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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(self.tabs[i].id)));
|
||
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(self.tabs[i].id));
|
||
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 => self
|
||
.tabs
|
||
.get(tab_idx)
|
||
.map(|tab| crate::app::PendingClose::Tab(tab.id)),
|
||
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")]
|
||
{
|
||
// The web counterpart of native's active_save_jobs guard: one
|
||
// capture → serialize → write/download chain at a time. The
|
||
// window is seconds, so refuse rather than queue — same
|
||
// localized message the native flow shows.
|
||
if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() {
|
||
if self.tabs.iter().any(|tab| tab.id == busy_tab) {
|
||
self.command_line
|
||
.push_info(crate::t!("Save already running for this drawing.").as_ref());
|
||
return Task::none();
|
||
}
|
||
// Stale marker (its tab closed before the save could
|
||
// settle): clear it instead of blocking every later save.
|
||
crate::io::fs_access::end_web_save(busy_tab);
|
||
}
|
||
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;
|
||
// Every caller of this entry (Save/QSAVE, the save-before-close
|
||
// flow) is plain-save semantics: on the web, the tab's retained
|
||
// FS Access handle may be written through directly. Save As opens
|
||
// the dialog modal instead (open_save_dialog_window resets this).
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
self.save_plain_reuse_fs_handle = true;
|
||
}
|
||
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 if ext.eq_ignore_ascii_case("dwt") {
|
||
("Drawing Template (*.dwt)", "dwt")
|
||
} 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 path — the destination is either a retained
|
||
// File System Access handle (in-place write), a fresh
|
||
// showSaveFilePicker pick, or the legacy 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);
|
||
self.tabs[i].scene.sync_native_parametric_graph();
|
||
let tab_id = self.tabs[i].id;
|
||
// File System Access routing, decided HERE: showSaveFilePicker
|
||
// may only run while the page holds the Save click's transient
|
||
// user activation, and the thumbnail capture + worker
|
||
// serialization that follow can outlast that window. So the
|
||
// handle is secured at this gesture moment (or reused from a
|
||
// previous pick — retained handles need no activation to write)
|
||
// and only the write happens later.
|
||
// A registered host save hook takes the bytes through the
|
||
// download path, so no file picker opens.
|
||
let route = crate::io::fs_access::save_route(
|
||
crate::io::fs_access::supported() && crate::sys::save_hook().is_none(),
|
||
self.save_plain_reuse_fs_handle,
|
||
crate::io::fs_access::handle_ext(tab_id).as_deref(),
|
||
ext,
|
||
);
|
||
match route {
|
||
crate::io::fs_access::SaveRoute::WriteInPlace => {
|
||
// Plain Save on a matching handle — THE point: Ctrl+S
|
||
// updates the picked file, no dialog at all. The real
|
||
// name (the user may have renamed it in the picker)
|
||
// drives the save bookkeeping.
|
||
let picked_name =
|
||
crate::io::fs_access::handle_name(tab_id).unwrap_or(filename);
|
||
Task::batch([
|
||
close,
|
||
self.web_save_capture(tab_id, picked_name, ext.to_string(), version),
|
||
])
|
||
}
|
||
crate::io::fs_access::SaveRoute::PickHandle => {
|
||
// The picker IS the save dialog here: it opens inside
|
||
// the click's activation window, before any serialize
|
||
// task starts. Cancelling it cancels the save (same as
|
||
// a native save-dialog cancel); a picker failure falls
|
||
// back to the legacy download.
|
||
let suggested = filename;
|
||
let pick_suggested = suggested.clone();
|
||
Task::batch([
|
||
close,
|
||
Task::perform(
|
||
async move {
|
||
crate::io::fs_access::pick_save_handle(
|
||
tab_id,
|
||
&pick_suggested,
|
||
ext,
|
||
)
|
||
.await
|
||
},
|
||
move |picked| Message::WebSaveHandlePicked {
|
||
tab_id,
|
||
filename: suggested,
|
||
ext: ext.to_string(),
|
||
version,
|
||
picked,
|
||
},
|
||
),
|
||
])
|
||
}
|
||
// Firefox/Safari (no showSaveFilePicker): untouched legacy
|
||
// download flow.
|
||
crate::io::fs_access::SaveRoute::Download => {
|
||
Task::batch([
|
||
close,
|
||
self.web_save_capture(tab_id, filename, ext.to_string(), version),
|
||
])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Web save, destination-known half shared by the legacy download path
|
||
/// and both File System Access paths: queue the viewport thumbnail
|
||
/// capture (or skip straight to the serializer when the bounds are
|
||
/// unavailable), which continues in `on_web_save_screenshot`.
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn web_save_capture(
|
||
&mut self,
|
||
tab_id: u64,
|
||
filename: String,
|
||
ext: String,
|
||
version: codec::DxfVersion,
|
||
) -> Task<Message> {
|
||
// The save is now committed to this tab and this format: mark it
|
||
// in flight (the web counterpart of native's active_save_jobs —
|
||
// on_save_file refuses a second save while the slot is held) and
|
||
// record the intended ext, which the completion half re-checks
|
||
// against the retained handle before writing.
|
||
crate::io::fs_access::begin_web_save(tab_id, &ext);
|
||
let bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID);
|
||
let Some(bounds) = bounds else {
|
||
return Task::done(Message::WebSaveScreenshot {
|
||
tab_id,
|
||
filename,
|
||
ext,
|
||
version,
|
||
bounds: None,
|
||
screenshot: None,
|
||
});
|
||
};
|
||
self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave {
|
||
tab_id,
|
||
filename,
|
||
ext,
|
||
version,
|
||
bounds,
|
||
});
|
||
self.thumbnail_capture_clean = true;
|
||
Task::none()
|
||
}
|
||
|
||
/// Web SAVE: the showSaveFilePicker promise from the confirm handler
|
||
/// settled. `Ok(Some(name))` continues the save against the picked
|
||
/// file's real name (the registry already retains the handle, replacing
|
||
/// any previous one — Save As re-picks); `Ok(None)` (dismissed picker)
|
||
/// cancels the save like a native save-dialog cancel; `Err` keeps the
|
||
/// save alive through the legacy download, with a note that the copy
|
||
/// was downloaded — after dropping any previously retained handle,
|
||
/// which was picked for the old format and must not receive the
|
||
/// fallback bytes.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_save_handle_picked(
|
||
&mut self,
|
||
tab_id: u64,
|
||
filename: String,
|
||
ext: String,
|
||
version: codec::DxfVersion,
|
||
picked: Result<Option<String>, String>,
|
||
) -> Task<Message> {
|
||
match picked {
|
||
Ok(Some(picked_name)) => {
|
||
self.web_save_capture(tab_id, picked_name, ext, version)
|
||
}
|
||
Ok(None) => {
|
||
// The picker was the save dialog: dismissing it means no
|
||
// save — the download fallback must NOT fire (that would
|
||
// surprise the user with a stray "drawing (1).dwg"). 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();
|
||
}
|
||
Task::none()
|
||
}
|
||
Err(error) => {
|
||
// The picker failed and the download fallback is about to
|
||
// run with bytes serialized for `ext` — but any handle still
|
||
// held was picked for (possibly) the OTHER format (Save As
|
||
// to DXF on a .dwg handle, …). Drop it now so the stale
|
||
// handle cannot receive the fallback bytes when they land;
|
||
// the next save re-picks.
|
||
crate::io::fs_access::drop_handle(tab_id);
|
||
self.command_line.push_error(
|
||
crate::tf!(
|
||
"Save destination picker failed: {error}; downloading a copy instead"
|
||
)
|
||
.as_ref(),
|
||
);
|
||
self.web_save_capture(tab_id, filename, ext, version)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The OPFS autosave snapshot key for `tab`: the drawing's display name
|
||
/// (the recent copy's name), or the tab title for untitled tabs — the
|
||
/// single derivation every web snapshot/remove site must agree on, or a
|
||
/// remove misses the snapshot a round wrote (and vice versa).
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn web_autosave_key(tab: &crate::app::document::DocumentTab) -> String {
|
||
tab.current_path
|
||
.as_ref()
|
||
.and_then(|path| path.file_name())
|
||
.map(|name| name.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| tab.tab_display_name())
|
||
}
|
||
|
||
/// 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_snapshot_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)
|
||
}
|
||
|
||
/// Periodic autosave on the web (SAVETIME): serialize every dirty tab to
|
||
/// an OPFS snapshot (`io::web_autosave`), the browser counterpart of the
|
||
/// native `.sv$` copy — best-effort and non-destructive: no download, no
|
||
/// recents update, dirty flag untouched. Snapshots run serially in one
|
||
/// round; while it lasts (or a web save's thumbnail capture is pending)
|
||
/// further ticks are dropped, not queued. Each tab is prepared (clone +
|
||
/// style sync + dimension bake) once up front, then the DWG/DXF writer
|
||
/// pass runs on the serializer worker — the UI thread only awaits the
|
||
/// result between OPFS writes.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_autosave(&mut self) -> Task<Message> {
|
||
if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() {
|
||
return Task::none();
|
||
}
|
||
let mut pending = Vec::new();
|
||
for i in 0..self.tabs.len() {
|
||
if !self.tabs[i].dirty {
|
||
continue;
|
||
}
|
||
// Same pre-serialize state sync the web Save flow performs
|
||
// (on_save_dialog_confirm): stamp the live camera, vport and
|
||
// sysvars into the document so the snapshot round-trips.
|
||
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);
|
||
self.tabs[i].scene.sync_native_parametric_graph();
|
||
let tab = &self.tabs[i];
|
||
// Key by the drawing's display name (the recent copy's name), so
|
||
// the boot scan can compare the two; untitled tabs use the tab
|
||
// title, which is what Save would download as.
|
||
let name = Self::web_autosave_key(tab);
|
||
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document);
|
||
let ext = if is_dxf { "dxf" } else { "dwg" }.to_string();
|
||
let version = tab.scene.document.version;
|
||
pending.push((
|
||
name,
|
||
ext,
|
||
crate::io::prepare_document_for_save(tab.scene.document.clone(), version),
|
||
));
|
||
}
|
||
if pending.is_empty() {
|
||
return Task::none();
|
||
}
|
||
self.web_autosave_in_flight = true;
|
||
Task::perform(
|
||
async move {
|
||
let mut failure = None;
|
||
for (name, ext, prepared) in pending {
|
||
// Autosave exists to recover geometry, not pretty recents:
|
||
// no thumbnail capture, just bytes into OPFS.
|
||
match crate::io::serialize_prepared_document(&prepared, &ext).await {
|
||
Ok(bytes) => {
|
||
if let Err(error) =
|
||
crate::io::web_autosave::snapshot(&name, &bytes, None).await
|
||
{
|
||
failure = Some(error);
|
||
}
|
||
}
|
||
Err(error) => failure = Some(error),
|
||
}
|
||
}
|
||
failure.map_or(Ok(()), Err)
|
||
},
|
||
Message::WebAutosaveSnapshotFinished,
|
||
)
|
||
}
|
||
|
||
/// One web autosave round finished. Silent on success; on failure one
|
||
/// command-line warning until a round succeeds again. Also the drain
|
||
/// point for snapshot removes a save deferred while the round was
|
||
/// writing: the round's writes are done, so the keys can be cleared
|
||
/// for real now (no late write can resurrect them).
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosave_finished(&mut self, result: Result<(), String>) -> Task<Message> {
|
||
self.web_autosave_in_flight = false;
|
||
let deferred = std::mem::take(&mut self.web_autosave_clear_after);
|
||
if !deferred.is_empty() {
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
for key in &deferred {
|
||
let _ = crate::io::web_autosave::remove(key).await;
|
||
}
|
||
});
|
||
}
|
||
match result {
|
||
Ok(()) => self.web_autosave_warned = false,
|
||
Err(error) => {
|
||
if !self.web_autosave_warned {
|
||
self.web_autosave_warned = true;
|
||
self.command_line.push_warning(
|
||
crate::tf!("Autosave copy could not be stored: {error}").as_ref(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// The boot scan decided which snapshots are recoverable; queue the
|
||
/// restore prompt (behind any already-queued startup modal). An empty
|
||
/// scan — a fresh profile, or everything already cleaned up — must not
|
||
/// surface an empty "Recover Drawing" modal, so the prompt is only
|
||
/// queued when there is something to offer. The remembered session list
|
||
/// is loaded right after the scan: it never opens anything itself, it
|
||
/// only names what the session had (a line in the restore modal) and
|
||
/// picks the active tab once restores complete.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosaves_scanned(
|
||
&mut self,
|
||
result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
|
||
) -> Task<Message> {
|
||
self.web_session = crate::io::web_session::load();
|
||
let Ok(mut entries) = result else {
|
||
// Best-effort feature: no storage access, no prompt.
|
||
return Task::none();
|
||
};
|
||
entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
|
||
self.web_autosave_pending = entries;
|
||
self.queue_autosave_restore_prompt();
|
||
Task::none()
|
||
}
|
||
|
||
/// Restore the offered snapshot: read the OPFS copy — leaving it in
|
||
/// place — and hand the bytes to the 50 ms poll's guarded open queue.
|
||
/// The snapshot is the only copy, so it is deleted only once the
|
||
/// recovered drawing actually opens (`web_autosave_opened`, on the
|
||
/// matching `WebFileOpened` success); a parse failure or cancel leaves
|
||
/// it recoverable for the next boot.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosave_restore(&mut self) -> Task<Message> {
|
||
let Some(entry) = self.web_autosave_pending.first().cloned() else {
|
||
self.close_active_modal();
|
||
return Task::none();
|
||
};
|
||
self.web_autosave_pending.remove(0);
|
||
self.close_active_modal();
|
||
let open_name = crate::io::web_autosave::recovered_name(&entry.name);
|
||
let key = entry.name;
|
||
// The snapshot key travels with the bytes: the marker (and the
|
||
// deletion it authorises) is recorded only when the poll actually
|
||
// starts this open, keyed by that open's id — never by display name.
|
||
let read_key = key.clone();
|
||
Task::perform(
|
||
async move { crate::io::web_autosave::read(&read_key).await },
|
||
move |result| Message::WebAutosaveBytes(open_name, key, result),
|
||
)
|
||
}
|
||
|
||
/// "Restore all": the whole pending list goes through the exact same
|
||
/// guarded path as a single restore — one read per snapshot, then
|
||
/// `enqueue_restore` with its own key, and the 50 ms poll opens them
|
||
/// sequentially. Nothing about consumption changes: each snapshot is
|
||
/// still deleted only when its own open succeeds (matched by open id),
|
||
/// and any failure (unreadable, parse error, cancel) keeps that
|
||
/// snapshot in OPFS for the next boot. The batch counts its settles in
|
||
/// `web_autosave_restore_all_remaining`; every settle path funnels
|
||
/// through `queue_next_web_autosave_prompt`, so the countdown — and the
|
||
/// remembered session's tab selection when it hits zero — needs no
|
||
/// extra bookkeeping sites.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosave_restore_all(&mut self) -> Task<Message> {
|
||
// Fewer than two pending: this is just Restore.
|
||
if self.web_autosave_pending.len() < 2 {
|
||
return self.on_web_autosave_restore();
|
||
}
|
||
let entries = std::mem::take(&mut self.web_autosave_pending);
|
||
self.web_autosave_restore_all_remaining = entries.len();
|
||
self.close_active_modal();
|
||
self.command_line.push_info(
|
||
crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(),
|
||
);
|
||
let reads = crate::io::web_autosave::restore_all_plan(&entries)
|
||
.into_iter()
|
||
.map(|(open_name, key)| {
|
||
// Same contract as the single restore: the key travels with
|
||
// the bytes so the deletion marker is recorded by open id.
|
||
let read_key = key.clone();
|
||
Task::perform(
|
||
async move { crate::io::web_autosave::read(&read_key).await },
|
||
move |result| Message::WebAutosaveBytes(open_name, key, result),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Task::batch(reads)
|
||
}
|
||
|
||
/// The recovered drawing opened: its autosave snapshot — held back until
|
||
/// now — can finally be deleted, and the next queued snapshot can be
|
||
/// offered. The caller already matched the marker to this open's id.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn web_autosave_opened(&mut self, open_name: &str) {
|
||
let Some((key, _)) = self.web_autosave_consumed.take() else {
|
||
return;
|
||
};
|
||
self.command_line
|
||
.push_output(crate::tf!("Restored \"{}\" from the autosave copy", open_name).as_ref());
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
let _ = crate::io::web_autosave::remove(&key).await;
|
||
});
|
||
self.queue_next_web_autosave_prompt();
|
||
}
|
||
|
||
/// Discard the offered snapshot; the prompt stays up for the next one.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosave_discard(&mut self) -> Task<Message> {
|
||
if let Some(entry) = self.web_autosave_pending.first().cloned() {
|
||
self.web_autosave_pending.remove(0);
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
let _ = crate::io::web_autosave::remove(&entry.name).await;
|
||
});
|
||
}
|
||
if self.web_autosave_pending.is_empty() {
|
||
self.close_active_modal();
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// Defer the offered snapshot (Escape/✕ on the restore modal): unlike
|
||
/// Discard the OPFS copy is NOT removed — one stray keystroke must not
|
||
/// destroy the only recovery copy — it is simply re-offered on the next
|
||
/// boot. The prompt moves on to the next queued snapshot, if any.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosave_defer(&mut self) -> Task<Message> {
|
||
if !self.web_autosave_pending.is_empty() {
|
||
self.web_autosave_pending.remove(0);
|
||
}
|
||
if self.web_autosave_pending.is_empty() {
|
||
self.close_active_modal();
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// The offered snapshot's bytes arrived (or did not). On success the
|
||
/// bytes ride the 50 ms poll's guarded open queue — the poll starts an
|
||
/// open only when the slot is free, so the recovered drawing can
|
||
/// neither clobber an open in flight nor be lost to one, and the
|
||
/// snapshot survives until that open lands (`web_autosave_opened`).
|
||
/// On failure the snapshot stays in OPFS (discardable on a later boot)
|
||
/// and the prompt moves on to the next one.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn on_web_autosave_bytes(
|
||
&mut self,
|
||
open_name: String,
|
||
key: String,
|
||
result: Result<Vec<u8>, String>,
|
||
) -> Task<Message> {
|
||
match result {
|
||
Ok(bytes) => {
|
||
// The next prompt is queued only when this open settles
|
||
// (`web_autosave_opened` / the failure hook), not here — the
|
||
// poll has not started the open yet, so queueing now would
|
||
// show the modal before the recovered drawing loads.
|
||
// Exception: a rejected enqueue (queue full) means no open
|
||
// will ever start for these bytes, so the prompt cycle must
|
||
// continue here or stall for the session. The snapshot
|
||
// survives in OPFS and is re-offered on the next boot.
|
||
if !crate::io::web_drop::enqueue_restore(open_name, bytes, key) {
|
||
self.command_line.push_warning(
|
||
crate::tf!("Autosave copy could not be queued: too many pending opens")
|
||
.as_ref(),
|
||
);
|
||
self.queue_next_web_autosave_prompt();
|
||
}
|
||
Task::none()
|
||
}
|
||
Err(error) => {
|
||
// Unreadable snapshot: warn, but neither delete it nor
|
||
// re-offer it this session — it was already taken out of
|
||
// `web_autosave_pending`, so the cycle cannot loop on it.
|
||
// It stays discardable on the next boot.
|
||
self.command_line
|
||
.push_warning(crate::tf!("Autosave copy could not be read: {error}").as_ref());
|
||
self.queue_next_web_autosave_prompt();
|
||
Task::none()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Re-offer the remaining boot snapshots once the current one settled.
|
||
/// No-op when none are left (or the modal is already queued). This is
|
||
/// also the single funnel every restore settle runs through (opened,
|
||
/// failed, cancelled, unreadable, or queue-rejected), so a Restore-all
|
||
/// batch counts down here — and when its last snapshot settles, the
|
||
/// remembered session decides which tab ends up active.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn queue_next_web_autosave_prompt(&mut self) {
|
||
if self.web_autosave_restore_all_remaining > 0 {
|
||
self.web_autosave_restore_all_remaining -= 1;
|
||
if self.web_autosave_restore_all_remaining == 0 {
|
||
// Applied at the next message boundary: the last restore's
|
||
// settle (`web_autosave_opened`) runs BEFORE its own open
|
||
// inserts the tab, so focusing here could not see it yet.
|
||
self.web_session_focus_pending = true;
|
||
}
|
||
}
|
||
if !self.web_autosave_pending.is_empty() {
|
||
self.queue_autosave_restore_prompt();
|
||
}
|
||
}
|
||
|
||
/// A Restore-all batch has fully settled: focus the tab the crashed
|
||
/// session remembered as active, if it actually came back (or is open
|
||
/// under its own name). Restored tabs carry the "(recovered)" marker —
|
||
/// `recovered_name` maps the remembered name onto it idempotently. A
|
||
/// no-op without remembered memory, or when the remembered tab did not
|
||
/// return; the last-opened tab then stays active, which is fine. Called
|
||
/// from the after-message hook (`web_session_focus_pending`), so the
|
||
/// whole settle batch — including the last tab insertion — is done.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn select_remembered_session_tab(&mut self) -> Task<Message> {
|
||
let Some(session) = self.web_session.as_ref() else {
|
||
return Task::none();
|
||
};
|
||
let Some(remembered) = session.tabs.get(session.active) else {
|
||
return Task::none();
|
||
};
|
||
let recovered = crate::io::web_autosave::recovered_name(remembered);
|
||
let Some(index) = self.tabs.iter().position(|tab| {
|
||
let name = tab.tab_display_name();
|
||
name == remembered.as_str() || name == recovered
|
||
}) else {
|
||
return Task::none();
|
||
};
|
||
self.active_tab = index;
|
||
// The active tab changed under the ribbon; re-sync its chips like
|
||
// every other tab switch does (#21).
|
||
self.sync_ribbon_layers();
|
||
self.sync_ribbon_styles();
|
||
self.sync_ribbon_from_selection();
|
||
|
||
Task::none()
|
||
}
|
||
|
||
/// Remember the open-tab list (names + active index) in localStorage —
|
||
/// the web session memory the next boot's restore flow consults. Cheap
|
||
/// by design: a small `Vec<String>` + index, rewritten in full. Called
|
||
/// on every save/open/close; the Start tab is not a drawing and is
|
||
/// skipped, with the active index remapped onto the filtered list.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(super) fn persist_web_session(&self) {
|
||
let mut names: Vec<String> = Vec::new();
|
||
let mut active = 0;
|
||
for (index, tab) in self.tabs.iter().enumerate() {
|
||
if tab.is_start {
|
||
continue;
|
||
}
|
||
if index == self.active_tab {
|
||
active = names.len();
|
||
}
|
||
names.push(tab.tab_display_name());
|
||
}
|
||
crate::io::web_session::persist(&names, active);
|
||
}
|
||
|
||
/// 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(&mut self) {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
for i in 0..self.tabs.len() {
|
||
let _ = std::fs::remove_file(self.autosave_target(i));
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
for path in &self.sheet_set.locks {
|
||
crate::app::commands::sheet_set::release_lock(path);
|
||
}
|
||
// Web counterpart: drop each open tab's OPFS snapshot, plus any
|
||
// removes a save deferred while a snapshot round was still writing.
|
||
// A tab close is not an exit, so this only runs on the explicit exit
|
||
// path; anything the teardown race leaves behind is caught by the
|
||
// next boot scan.
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let mut keys = std::mem::take(&mut self.web_autosave_clear_after);
|
||
// An in-flight round may still be writing snapshots right now;
|
||
// its late writes would land AFTER these removes and resurrect
|
||
// exactly what was removed, so the per-tab sweep is pointless in
|
||
// that race — skip it entirely (the boot scan bounds leftovers:
|
||
// `SNAPSHOT_CAP` eviction discards the oldest overflow).
|
||
if !self.web_autosave_in_flight {
|
||
for tab in &self.tabs {
|
||
keys.push(Self::web_autosave_key(tab));
|
||
}
|
||
}
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
for key in &keys {
|
||
let _ = crate::io::web_autosave::remove(key).await;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Remove the autosave recovery files, then quit the application.
|
||
pub(in crate::app) fn exit_app(&mut self) -> Task<Message> {
|
||
self.cleanup_autosaves();
|
||
iced::exit()
|
||
}
|
||
|
||
/// The plot scale as `paper : drawing` in the dialog's page-setup unit,
|
||
/// with the standard code it names when it names one. The dialog's
|
||
/// factor is per the drawing's scale-family unit (inches for an imperial
|
||
/// drawing, else millimetres) and already includes the drawing-unit
|
||
/// factor; the page setup counts per its own unit. A named ratio is kept
|
||
/// as written when no drawing-unit factor hides inside it — a metre
|
||
/// drawing plotting "1:100" really plots 10 mm per unit, which is what
|
||
/// the file must say — and its standard code is kept even across a
|
||
/// paper-unit conversion, the way the commercial application writes
|
||
/// "1:1" on an inch page setup over a millimetre paper space as
|
||
/// `1 in = 25.4 units`.
|
||
fn dialog_plot_scale_ratio(&self) -> ((f64, f64), Option<codec::objects::ScaledType>) {
|
||
let d = &self.plot_dialog;
|
||
let scene = &self.tabs[self.active_tab].scene;
|
||
let factor = plot_dialog_scale_factor(d);
|
||
let conversion = scene.scale_family_unit_mm() / d.paper_units.to_mm(1.0);
|
||
let named = scene.scale_ratio(&d.scale).filter(|(paper, drawing)| {
|
||
(paper / drawing - factor).abs() <= 1e-9 * factor.abs().max(1.0)
|
||
});
|
||
let standard = named.and_then(|(paper, drawing)| standard_scale_for_ratio(paper, drawing));
|
||
let ratio = match named {
|
||
Some((paper, drawing)) if (conversion - 1.0).abs() <= 1e-12 => (paper, drawing),
|
||
_ => {
|
||
let per_unit = (factor * conversion).max(1e-9);
|
||
if per_unit >= 1.0 {
|
||
(per_unit, 1.0)
|
||
} else {
|
||
(1.0, 1.0 / per_unit)
|
||
}
|
||
}
|
||
};
|
||
(ratio, standard)
|
||
}
|
||
|
||
/// `PAPERUPDATE`: when the printer that just answered does not list the
|
||
/// dialog's sheet, either switch to the printer's default sheet (1) or
|
||
/// keep the sheet and say so (0).
|
||
fn reconcile_sheet_with_printer(&mut self, printer: &str) {
|
||
let d = &self.plot_dialog;
|
||
let Some(caps) = d.printer_media.as_ref() else {
|
||
return;
|
||
};
|
||
if caps.media.is_empty() {
|
||
return;
|
||
}
|
||
let paper = plot_dialog_paper(d);
|
||
if caps.margins_for(&paper).is_some() {
|
||
return;
|
||
}
|
||
let sheet = paper.display();
|
||
if d.paper_update == 1 {
|
||
let Some(default) = caps
|
||
.default_paper
|
||
.clone()
|
||
.or_else(|| caps.media.first().map(|media| media.paper.clone()))
|
||
else {
|
||
return;
|
||
};
|
||
let d = &mut self.plot_dialog;
|
||
d.paper = default.canonical.to_string();
|
||
(d.paper_width_mm, d.paper_height_mm) = default.sheet_mm(plot_dialog_orientation(d));
|
||
let default = default.display();
|
||
self.command_line.push_info(
|
||
crate::tf!(
|
||
"{printer} does not support {sheet}; switched to its default sheet \
|
||
{default} (PAPERUPDATE = 1)."
|
||
)
|
||
.as_ref(),
|
||
);
|
||
} else {
|
||
self.command_line.push_info(
|
||
crate::tf!(
|
||
"{printer} does not support {sheet}; the sheet is kept (PAPERUPDATE = 0)."
|
||
)
|
||
.as_ref(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Spell the picked scale in the custom fields.
|
||
fn refresh_custom_scale_fields(&mut self) {
|
||
let ((paper, drawing), _) = self.dialog_plot_scale_ratio();
|
||
let d = &mut self.plot_dialog;
|
||
d.custom_scale_paper = trim_decimals(&format!("{paper:.6}"));
|
||
d.custom_scale_drawing = trim_decimals(&format!("{drawing:.6}"));
|
||
}
|
||
|
||
/// Make the typed custom ratio the plot scale: the scale list's name for
|
||
/// it when it has one, else the ratio itself.
|
||
fn adopt_custom_scale(&mut self) {
|
||
let d = &self.plot_dialog;
|
||
if d.area == "Layout" {
|
||
return;
|
||
}
|
||
let (Ok(paper), Ok(drawing)) = (
|
||
d.custom_scale_paper.trim().parse::<f64>(),
|
||
d.custom_scale_drawing.trim().parse::<f64>(),
|
||
) else {
|
||
return;
|
||
};
|
||
if !(paper > 0.0 && drawing > 0.0 && paper.is_finite() && drawing.is_finite()) {
|
||
return;
|
||
}
|
||
let conversion =
|
||
self.tabs[self.active_tab].scene.scale_family_unit_mm() / d.paper_units.to_mm(1.0);
|
||
let factor = paper / drawing / conversion;
|
||
let d = &mut self.plot_dialog;
|
||
d.scale = scale_name_for_factor(&d.scales, factor).unwrap_or_else(|| {
|
||
let name = ratio_scale_name(factor);
|
||
if !d.scales.iter().any(|(n, _)| n == &name) {
|
||
d.scales.push((name.clone(), factor));
|
||
d.scales
|
||
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||
}
|
||
name
|
||
});
|
||
}
|
||
|
||
/// The plot origin in millimetres from the sheet's corner: the dialog's
|
||
/// offsets in the page-setup unit, counted from the printable area's
|
||
/// corner unless `PLOTOFFSET` says from the paper edge.
|
||
fn dialog_plot_origin_mm(&self) -> (f64, f64) {
|
||
let d = &self.plot_dialog;
|
||
let x = d
|
||
.paper_units
|
||
.to_mm(d.offset_x.trim().parse::<f64>().unwrap_or(0.0));
|
||
let y = d
|
||
.paper_units
|
||
.to_mm(d.offset_y.trim().parse::<f64>().unwrap_or(0.0));
|
||
if d.plot_offset_from_edge {
|
||
return (x, y);
|
||
}
|
||
let ps = self.plot_settings_from_dialog(self.dialog_base_settings());
|
||
let m = ps.margins;
|
||
let (left, bottom, ..) = crate::scene::rotated_margins(
|
||
(m.left, m.bottom, m.right, m.top),
|
||
ps.rotation.to_code(),
|
||
);
|
||
(x + left, y + bottom)
|
||
}
|
||
|
||
/// The settings the dialog edits on top of: the named page setup it was
|
||
/// loaded from, else the active layout's own, else a fresh metric sheet.
|
||
/// Everything the dialog has no control for (viewport-border and
|
||
/// paper-update flags, the plot view handle, the paper unit that fixes
|
||
/// the layout's coordinate system) is carried over from here untouched.
|
||
fn dialog_base_settings(&self) -> codec::objects::PlotSettings {
|
||
use codec::objects::{PlotPaperUnits, PlotSettings};
|
||
let scene = &self.tabs[self.active_tab].scene;
|
||
self.plot_setup_template
|
||
.clone()
|
||
.or_else(|| scene.plot_settings_for(&scene.current_layout))
|
||
.unwrap_or_else(|| {
|
||
let mut settings = PlotSettings::new("");
|
||
settings.paper_units = PlotPaperUnits::Millimeters;
|
||
settings
|
||
})
|
||
}
|
||
|
||
/// The page setup the dialog describes, written the way the file format
|
||
/// expects it: the portrait medium with the orientation in `rotation`,
|
||
/// standard scales by their code, custom ones as a readable ratio in the
|
||
/// layout's own paper unit. Named page setups and the layout's embedded
|
||
/// settings both come from here so the two can never drift apart.
|
||
fn plot_settings_from_dialog(
|
||
&self,
|
||
mut ps: codec::objects::PlotSettings,
|
||
) -> codec::objects::PlotSettings {
|
||
use crate::io::paper_catalog::Orientation;
|
||
use codec::objects::{
|
||
PlotPaperUnits, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel,
|
||
};
|
||
let d = &self.plot_dialog;
|
||
// Names and margins are decided against the stored settings before
|
||
// any field is overwritten.
|
||
let paper_size = paper_name_for_settings(d, &ps);
|
||
let printer_name = device_name_for_settings(d, &ps);
|
||
let margins = margins_for_settings(d, &ps);
|
||
let same_medium = paper_unchanged(d, &ps) && ps.paper_width > 0.0 && ps.paper_height > 0.0;
|
||
ps.paper_size = paper_size;
|
||
ps.printer_name = printer_name;
|
||
ps.margins = margins;
|
||
// An unchanged sheet keeps the medium exactly as the drawing stores
|
||
// it — its driver's precise dimensions in the orientation the driver
|
||
// defines it (a PDF driver lists landscape media of its own) — and
|
||
// only the rotation says how the sheet is plotted on it. A new
|
||
// selection is written as the catalogue's portrait medium.
|
||
let natural = if same_medium {
|
||
if ps.paper_width > ps.paper_height {
|
||
Orientation::Landscape
|
||
} else {
|
||
Orientation::Portrait
|
||
}
|
||
} else {
|
||
let (width, height) = plot_dialog_paper(d).portrait_mm();
|
||
ps.paper_width = width.max(1.0);
|
||
ps.paper_height = height.max(1.0);
|
||
Orientation::Portrait
|
||
};
|
||
ps.rotation = plot_dialog_rotation(d, natural);
|
||
let layout_area = d.area == "Layout";
|
||
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 && !layout_area;
|
||
// Offsets are typed in the page-setup unit; the file keeps millimetres.
|
||
let (origin_x, origin_y) = if layout_area {
|
||
(0.0, 0.0)
|
||
} else {
|
||
(
|
||
d.paper_units
|
||
.to_mm(d.offset_x.trim().parse::<f64>().unwrap_or(0.0)),
|
||
d.paper_units
|
||
.to_mm(d.offset_y.trim().parse::<f64>().unwrap_or(0.0)),
|
||
)
|
||
};
|
||
ps.origin_x = origin_x;
|
||
ps.origin_y = origin_y;
|
||
ps.paper_units = match d.paper_units {
|
||
crate::io::paper_catalog::PaperUnits::Inches => PlotPaperUnits::Inches,
|
||
crate::io::paper_catalog::PaperUnits::Millimeters => PlotPaperUnits::Millimeters,
|
||
};
|
||
if d.fit_to_paper && !layout_area {
|
||
ps.set_scale_to_fit();
|
||
// The commercial application marks fitted plots with the
|
||
// "standard scale" bit and leaves the ratio it computed.
|
||
ps.flags.use_standard_scale = true;
|
||
} else {
|
||
// A layout plot goes through the same path as the other areas:
|
||
// the dialog holds it at 1:1, which the unit conversion turns
|
||
// into whatever keeps the paper space's unit intact.
|
||
let ((numerator, denominator), standard) = self.dialog_plot_scale_ratio();
|
||
// The commercial application leaves the "standard scale" bit
|
||
// clear even for a standard code and keeps the ratio in the
|
||
// numerator/denominator pair; readers that trust the bit would
|
||
// otherwise take the stored factor as the ratio itself.
|
||
ps.scale_type = standard.unwrap_or(ScaledType::CustomScale);
|
||
ps.flags.use_standard_scale = false;
|
||
ps.scale_numerator = numerator;
|
||
ps.scale_denominator = denominator;
|
||
ps.standard_scale_factor = stored_scale_factor(numerator / denominator, ps.paper_units);
|
||
}
|
||
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;
|
||
ps.flags.show_plot_styles = d.show_plot_styles;
|
||
ps.flags.draw_viewports_first = d.paperspace_last;
|
||
ps.flags.plot_hidden = d.hide_paperspace;
|
||
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
|
||
}
|
||
|
||
/// Write the dialog's page setup 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.
|
||
pub(super) fn apply_plot_page_settings(&mut self) {
|
||
let i = self.active_tab;
|
||
let layout_name = self.tabs[i].scene.current_layout.clone();
|
||
let ps = self.plot_settings_from_dialog(self.dialog_base_settings());
|
||
if layout_name == "Model" {
|
||
// Model keeps its page setup too (#1620); it has no sheet limits.
|
||
self.tabs[i]
|
||
.scene
|
||
.set_layout_plot_settings(&layout_name, &ps);
|
||
self.tabs[i].dirty = true;
|
||
return;
|
||
}
|
||
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
|
||
let plot_area = self.plot_dialog.area.clone();
|
||
let center = ps.flags.plot_centered;
|
||
let rotation = ps.rotation.to_degrees() as i32;
|
||
self.tabs[i]
|
||
.scene
|
||
.set_layout_plot_settings(&layout_name, &ps);
|
||
// Layout limits are the sheet in paper-space units, which the
|
||
// settings just written define, placed the way the commercial
|
||
// application places it: the printable area's corner is the paper
|
||
// space origin, so the sheet starts at minus its displayed margins.
|
||
let units_per_mm = self.tabs[i].scene.paper_space_unit_factor().max(1e-9);
|
||
let m = ps.margins;
|
||
let (left, bottom, ..) = crate::scene::rotated_margins(
|
||
(m.left, m.bottom, m.right, m.top),
|
||
ps.rotation.to_code(),
|
||
);
|
||
let (x0, y0) = (-left * units_per_mm, -bottom * units_per_mm);
|
||
let (lw, lh) = (w * units_per_mm, h * units_per_mm);
|
||
for obj in self.tabs[i].scene.document.objects.values_mut() {
|
||
if let codec::objects::ObjectType::Layout(layout) = obj {
|
||
if layout.name == layout_name {
|
||
layout.min_limits = (x0, y0);
|
||
layout.max_limits = (x0 + lw, y0 + lh);
|
||
layout.min_extents = (x0, y0, 0.0);
|
||
layout.max_extents = (x0 + lw, y0 + lh, 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();
|
||
// This load only refreshes paper/scale context from the layout;
|
||
// the device the user just plotted with must survive it.
|
||
let kept_printer = self.plot_dialog.printer.clone();
|
||
let kept_to_file = self.plot_dialog.to_file;
|
||
if let Some(settings) = self.tabs[i].scene.effective_plot_settings() {
|
||
self.load_plotsettings_into_dialog(&settings);
|
||
}
|
||
self.plot_dialog.printer = kept_printer;
|
||
self.plot_dialog.to_file = kept_to_file;
|
||
}
|
||
let Some(page) = self.direct_plot_page() else {
|
||
self.command_line.push_error(
|
||
crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(),
|
||
);
|
||
return Task::none();
|
||
};
|
||
let worker_path = path.clone();
|
||
let work = move || {
|
||
// SVG shares the plot page with PDF: pick the writer from the
|
||
// chosen extension.
|
||
let result = if worker_path
|
||
.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
|
||
{
|
||
crate::io::svg_export::export_svg(&page, &worker_path)
|
||
} else {
|
||
crate::io::pdf_export::export_pdf(&page, &worker_path)
|
||
};
|
||
result
|
||
.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(page) = job else {
|
||
self.command_line
|
||
.push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref());
|
||
return Task::none();
|
||
};
|
||
let worker_path = path.clone();
|
||
self.close_active_modal();
|
||
let work = move || {
|
||
let result = if worker_path
|
||
.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
|
||
{
|
||
crate::io::svg_export::export_svg(&page, &worker_path)
|
||
} else {
|
||
crate::io::pdf_export::export_pdf(&page, &worker_path)
|
||
};
|
||
result
|
||
.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;
|
||
|
||
// Refresh runtime-only data such as printers, available media,
|
||
// CTB names, scales and page-setup rows.
|
||
//
|
||
// `on_plot_dialog_open()` also seeds the editor from the active
|
||
// layout. That is correct for normal PLOT, but Print All Options is a
|
||
// shared batch override editor: its persisted user choices must win.
|
||
let task = self.on_plot_dialog_open();
|
||
|
||
// Restore only user-editable values. `copy_settings_from` deliberately
|
||
// leaves the freshly refreshed runtime lists intact.
|
||
//
|
||
// Do this even on the first Print All Options opening after restarting
|
||
// OCS. Previously this happened only when `print_all_settings_override`
|
||
// was already true, so the first opening always fell back to the
|
||
// active layout/default A4 and lost the persisted CTB selection.
|
||
self.plot_dialog.copy_settings_from(&previous);
|
||
|
||
// Preserve the parsed CTB when it matches the restored selection.
|
||
// If it is absent/stale, dialog_plot_style() can resolve it by name.
|
||
self.active_plot_style = previous_style.clone();
|
||
self.plot_window = previous_window;
|
||
|
||
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_options = true;
|
||
self.plot_dialog.paper_space = true;
|
||
self.plot_dialog.area = "Layout".into();
|
||
|
||
task
|
||
}
|
||
|
||
/// A plot gives its fields (PlotDate) the plot time, as the reference
|
||
/// stores it; the hosts are redrawn before the plot reads the scene.
|
||
pub(in crate::app) fn stamp_plot_fields(&mut self) {
|
||
let i = self.active_tab;
|
||
let hosts = crate::entities::field::stamp_plot_fields(&mut self.tabs[i].scene.document);
|
||
if !hosts.is_empty() {
|
||
self.tabs[i].scene.bump_text_hosts(&hosts);
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
}
|
||
|
||
fn print_all_pages(&mut self) -> Result<Vec<crate::io::pdf_export::PdfPageInput>, String> {
|
||
self.stamp_plot_fields();
|
||
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.plot_dialog.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 area_page = 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 page = if dialog.area == "Layout" {
|
||
self.layout_plot_page_for("Layout")
|
||
} else {
|
||
area_page.ok_or_else(|| {
|
||
crate::tf!("Layout '{name}' plot area is empty.").into_owned()
|
||
})?
|
||
};
|
||
pages.push(page);
|
||
}
|
||
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();
|
||
let fallback_style = self.dialog_plot_style(&dialog);
|
||
self.save_config();
|
||
self.close_active_modal();
|
||
let work = move || {
|
||
crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, fallback_style.as_ref())
|
||
.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_publish, work)
|
||
}
|
||
|
||
pub(super) fn on_print_all_print(&mut self) -> 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();
|
||
}
|
||
};
|
||
self.save_config();
|
||
self.close_active_modal();
|
||
// The web build has no system printers: render every layout into one
|
||
// PDF and open it in a browser tab for the browser print dialog.
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let stem = self.active_stem("_layouts");
|
||
let work = move || {
|
||
crate::io::print_to_printer::preview_pages_pdf(&stem, &pages)
|
||
.map(|_| crate::t!("Opened plot preview.").into_owned())
|
||
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
|
||
};
|
||
self.run_print_all_work(true, work)
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let options = self.plot_print_options(&dialog);
|
||
let fallback_style = self.dialog_plot_style(&dialog);
|
||
let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all");
|
||
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, fallback_style.as_ref())
|
||
.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(crate::t!("Print all"), 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(crate::t!("Plot"), work, move |result| {
|
||
Message::BackgroundIoFinished(result, reopen_plot)
|
||
})
|
||
} else {
|
||
let result = work();
|
||
self.report_plot_result(result, reopen_plot);
|
||
Task::none()
|
||
}
|
||
}
|
||
|
||
/// Assemble a layout page for PDF export, preview, or printing.
|
||
pub(super) fn layout_plot_page(&self) -> PdfPageInput {
|
||
self.layout_plot_page_for(&self.plot_dialog.area)
|
||
}
|
||
|
||
pub(in crate::app) fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput {
|
||
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 mut content = 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.
|
||
if paper_space {
|
||
content.group_splits.wires = content.wires
|
||
[..content.group_splits.wires.min(content.wires.len())]
|
||
.iter()
|
||
.filter(|wire| wire.name != "paper_printable_area")
|
||
.count();
|
||
std::sync::Arc::make_mut(&mut content.wires)
|
||
.retain(|wire| wire.name != "paper_printable_area");
|
||
}
|
||
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, origin_y) = self.dialog_plot_origin_mm();
|
||
|
||
let (scale, offset_x, offset_y) = if plot_extents {
|
||
let (min_x, min_y, max_x, max_y) =
|
||
plot_content_extents(&content).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 PdfPageInput {
|
||
content,
|
||
paper_w: page_w,
|
||
paper_h: page_h,
|
||
offset_x,
|
||
offset_y,
|
||
rotation_deg: rotation,
|
||
scale: scale as f32,
|
||
clip,
|
||
options: Self::pdf_plot_options(&self.plot_dialog),
|
||
plot_style: self.dialog_plot_style(&self.plot_dialog),
|
||
};
|
||
}
|
||
|
||
// Model space keeps its established extents + 5% margin behaviour.
|
||
// A sheet edge below this floor (drawing with tiny extents, or the
|
||
// 0×0 paper a fresh unsaved drawing carries) cannot be previewed,
|
||
// printed, or measured meaningfully; the standard-sheet fallback
|
||
// keeps the output a real page instead of a speck.
|
||
const MIN_MODEL_SHEET_MM: f64 = 10.0;
|
||
let standard_sheet = {
|
||
let (w, h) = selected_sheet;
|
||
(w.min(h) >= MIN_MODEL_SHEET_MM).then_some((w, h))
|
||
};
|
||
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);
|
||
if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM {
|
||
// Per-axis: a thin-but-long profile keeps its long
|
||
// dimension instead of being clipped to the sheet, and
|
||
// the content is centered on the enlarged page.
|
||
let (w, h) = standard_sheet.unwrap_or((297.0, 210.0));
|
||
let page_w = width.max(w);
|
||
let page_h = height.max(h);
|
||
(
|
||
page_w,
|
||
page_h,
|
||
(page_w - width) * 0.5 - min.x as f64,
|
||
(page_h - height) * 0.5 - min.y as f64,
|
||
)
|
||
} else {
|
||
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)
|
||
};
|
||
PdfPageInput {
|
||
content,
|
||
paper_w: page_w,
|
||
paper_h: page_h,
|
||
offset_x,
|
||
offset_y,
|
||
rotation_deg: 0,
|
||
scale: 1.0,
|
||
clip: None,
|
||
options: Self::pdf_plot_options(&self.plot_dialog),
|
||
plot_style: self.dialog_plot_style(&self.plot_dialog),
|
||
}
|
||
}
|
||
|
||
/// 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_page(&self) -> Option<PdfPageInput> {
|
||
if self.tabs[self.active_tab].scene.current_layout != "Model" {
|
||
return Some(self.layout_plot_page_for("Layout"));
|
||
}
|
||
let page = self.extents_plot_job()?;
|
||
let content = &page.content;
|
||
if content.wires.is_empty()
|
||
&& content.hatches.is_empty()
|
||
&& content.wipeouts.is_empty()
|
||
&& content.images.is_empty()
|
||
{
|
||
return None;
|
||
}
|
||
Some(page)
|
||
}
|
||
|
||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||
let Some(page) = self.direct_plot_page() else {
|
||
self.command_line.push_error(
|
||
crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(),
|
||
);
|
||
return Task::none();
|
||
};
|
||
// The web build has no system printers: PRINT opens the rendered PDF
|
||
// in a browser tab, where the browser's own print dialog takes over.
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let stem = self.active_stem("");
|
||
background_task(
|
||
crate::t!("Plot preview"),
|
||
move || {
|
||
crate::io::print_to_printer::preview_page_pdf(&stem, &page)
|
||
.map(|_| crate::t!("Opened plot preview.").into_owned())
|
||
.map_err(|e| crate::tf!("Preview failed: {e}").into_owned())
|
||
},
|
||
|result| Message::BackgroundIoFinished(result, false),
|
||
)
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let options = self.plot_print_options(&self.plot_dialog);
|
||
self.command_line
|
||
.push_info(crate::t!("Sending to system printer…").as_ref());
|
||
background_task(
|
||
crate::t!("Print to printer"),
|
||
move || {
|
||
iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with(
|
||
page, 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<codec::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<codec::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"))
|
||
}
|
||
|
||
/// File stem of the active drawing for default output names, with an
|
||
/// optional suffix (e.g. "_layouts" for print-all). Falls back to
|
||
/// "drawing" when the tab has never been saved.
|
||
fn active_stem(&self, suffix: &str) -> String {
|
||
self.tabs[self.active_tab]
|
||
.current_path
|
||
.as_deref()
|
||
.and_then(|path| path.file_stem())
|
||
.map(|stem| format!("{}{}", stem.to_string_lossy(), suffix))
|
||
.unwrap_or_else(|| format!("drawing{suffix}"))
|
||
}
|
||
|
||
/// 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_catalog::Orientation;
|
||
|
||
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
|
||
let same_context = self
|
||
.plot_prev_context
|
||
.as_ref()
|
||
.is_some_and(|(tab, layout)| *tab == self.active_tab && layout == &cur);
|
||
|
||
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;
|
||
match crate::io::print_to_printer::list_printers() {
|
||
Ok(printers) => {
|
||
d.printers = printers;
|
||
d.printers_error = None;
|
||
}
|
||
Err(error) => {
|
||
d.printers = Vec::new();
|
||
d.printers_error = Some(error);
|
||
}
|
||
}
|
||
d.default_printer = crate::io::plot_device::default_printer_name();
|
||
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_paper.canonical.to_string();
|
||
(d.paper_width_mm, d.paper_height_mm) = self.plot_paper.sheet_mm(self.plot_orientation);
|
||
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();
|
||
if let Some(error) = self.plot_dialog.printers_error.clone() {
|
||
self.command_line
|
||
.push_warning(crate::tf!("Could not list printers: {error}").as_ref());
|
||
}
|
||
self.plot_prev = Some(previous.clone());
|
||
let layout_entry = format!("*{cur}*");
|
||
|
||
if same_context {
|
||
// #1654: reopening Plot for the same drawing/layout must keep the
|
||
// values the user just edited. The layout page setup is the initial
|
||
// seed, not something that should overwrite paper, scale, CTB,
|
||
// orientation, area and output choices on every Ctrl+P.
|
||
//
|
||
// `copy_settings_from` deliberately leaves refreshed runtime lists
|
||
// (printers, media, scales, page setups, etc.) untouched.
|
||
self.plot_dialog.copy_settings_from(&previous);
|
||
self.plot_dialog.selected_setup = previous.selected_setup.clone();
|
||
|
||
// A custom ratio such as 1:3 may not exist in the drawing's named
|
||
// scale list. select_page_setup() normally adds it while loading a
|
||
// PlotSettings, but same-context reopening intentionally skips that
|
||
// reload. Keep the restored custom scale available to the picker so
|
||
// the validation below does not collapse it to 1:1.
|
||
if !self.plot_dialog.fit_to_paper
|
||
&& !self
|
||
.plot_dialog
|
||
.scales
|
||
.iter()
|
||
.any(|(name, _)| name == &self.plot_dialog.scale)
|
||
{
|
||
let factor = plot_dialog_scale_factor(&self.plot_dialog);
|
||
|
||
if factor.is_finite() && factor > 0.0 {
|
||
let name = self.plot_dialog.scale.clone();
|
||
self.plot_dialog.scales.push((name, factor));
|
||
self.plot_dialog
|
||
.scales
|
||
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||
}
|
||
}
|
||
} else {
|
||
// First open for this tab/layout: seed the editor from its actual
|
||
// page setup. An explicit setup selection later still uses
|
||
// `select_page_setup` and therefore intentionally replaces fields.
|
||
//
|
||
// Keep the chosen output device when a drawing page setup does not
|
||
// name one of its own.
|
||
let kept_printer = self.plot_dialog.printer.clone();
|
||
let kept_to_file = self.plot_dialog.to_file;
|
||
|
||
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);
|
||
}
|
||
|
||
let setup_named_a_device =
|
||
self.plot_dialog.printer.is_some() || self.plot_dialog.to_file;
|
||
if !setup_named_a_device {
|
||
self.plot_dialog.printer = kept_printer;
|
||
self.plot_dialog.to_file = kept_to_file;
|
||
}
|
||
}
|
||
|
||
self.plot_prev_context = Some((self.active_tab, cur.clone()));
|
||
// A persisted plot window (saved with the dialog settings) restores
|
||
// across sessions; a freshly loaded one wins so the two stay in sync.
|
||
if self.plot_window.is_none() {
|
||
self.plot_window = self.plot_dialog.window;
|
||
} else {
|
||
self.plot_dialog.window = self.plot_window;
|
||
}
|
||
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);
|
||
// Loading printer capabilities is informational when Plot merely
|
||
// opens. It must not replace the sheet stored in the drawing.
|
||
self.request_printer_media(false)
|
||
}
|
||
|
||
/// Keep the dialog's view of the selected printer's media current: use the
|
||
/// session cache when the printer was asked before, otherwise ask it in
|
||
/// the background and clear the stale answer meanwhile. PDF output and
|
||
/// the default printer list the catalogue instead.
|
||
fn request_printer_media(&mut self, reconcile: bool) -> Task<Message> {
|
||
let Some(printer) = (!self.plot_dialog.to_file)
|
||
.then(|| self.plot_dialog.printer.clone())
|
||
.flatten()
|
||
else {
|
||
self.plot_dialog.printer_media = None;
|
||
self.plot_reconcile_printer_media = None;
|
||
return Task::none();
|
||
};
|
||
|
||
// PAPERUPDATE is relevant after the user explicitly changes the
|
||
// device/page setup. Merely reopening Plot must leave the drawing's
|
||
// stored paper untouched.
|
||
self.plot_reconcile_printer_media = reconcile.then(|| printer.clone());
|
||
|
||
if let Some(caps) = crate::io::plot_device::cached_printer_capabilities(&printer) {
|
||
self.plot_dialog.printer_media = Some(caps);
|
||
|
||
if reconcile {
|
||
self.reconcile_sheet_with_printer(&printer);
|
||
}
|
||
|
||
return Task::none();
|
||
}
|
||
|
||
self.plot_dialog.printer_media = None;
|
||
let name = printer.clone();
|
||
|
||
background_task(
|
||
crate::t!("Printer capabilities"),
|
||
move || crate::io::plot_device::printer_capabilities(&printer),
|
||
move |caps| {
|
||
Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(
|
||
name, caps,
|
||
))
|
||
},
|
||
)
|
||
}
|
||
|
||
/// The inline custom-sheet editor of the Plot dialog. A sheet is added
|
||
/// to (or replaced in) the persisted list under its media name and
|
||
/// selected at once; the list is saved immediately so it survives a
|
||
/// crash the way a plotter configuration would.
|
||
fn on_custom_paper_msg(&mut self, msg: crate::ui::window::plot::CustomPaperMsg) {
|
||
use crate::ui::window::plot::{CustomPaperDraft, CustomPaperMsg as C, MarginSide};
|
||
let d = &mut self.plot_dialog;
|
||
match msg {
|
||
C::Open => {
|
||
let paper = plot_dialog_paper(d);
|
||
let existing = d
|
||
.custom_papers
|
||
.iter()
|
||
.find(|custom| custom.canonical() == paper.canonical);
|
||
let margins = match existing {
|
||
Some(custom) => custom.margins_mm(),
|
||
None => plot_dialog_device(d).margins_mm(&paper, &d.custom_papers),
|
||
};
|
||
let mut draft = CustomPaperDraft::from_paper(&paper, margins);
|
||
// A catalogue sheet is only the starting size; the new sheet
|
||
// gets its own name (the source application's generic one when left blank).
|
||
if existing.is_none() {
|
||
draft.name.clear();
|
||
}
|
||
d.custom_editor = Some(draft);
|
||
}
|
||
C::Cancel => d.custom_editor = None,
|
||
C::Name(value) => Self::edit_custom_draft(d, |draft| draft.name = value),
|
||
C::Width(value) => Self::edit_custom_draft(d, |draft| draft.width = value),
|
||
C::Height(value) => Self::edit_custom_draft(d, |draft| draft.height = value),
|
||
C::Units(value) => Self::edit_custom_draft(d, |draft| draft.inches = value == "Inches"),
|
||
C::Margin(side, value) => Self::edit_custom_draft(d, |draft| {
|
||
let index = match side {
|
||
MarginSide::Left => 0,
|
||
MarginSide::Bottom => 1,
|
||
MarginSide::Right => 2,
|
||
MarginSide::Top => 3,
|
||
};
|
||
draft.margins[index] = value;
|
||
}),
|
||
C::Add => {
|
||
let Some(draft) = d.custom_editor.as_mut() else {
|
||
return;
|
||
};
|
||
match draft.build() {
|
||
Ok(custom) => {
|
||
let canonical = custom.canonical();
|
||
d.custom_papers
|
||
.retain(|existing| existing.canonical() != canonical);
|
||
d.custom_papers.push(custom);
|
||
d.custom_editor = None;
|
||
d.paper = canonical;
|
||
let (w, h) = plot_dialog_sheet_mm(d);
|
||
d.paper_width_mm = w;
|
||
d.paper_height_mm = h;
|
||
self.save_config();
|
||
}
|
||
Err(error) => draft.error = Some(error),
|
||
}
|
||
}
|
||
C::Remove => {
|
||
let selected = plot_dialog_paper(d).canonical.into_owned();
|
||
let before = d.custom_papers.len();
|
||
d.custom_papers
|
||
.retain(|custom| custom.canonical() != selected);
|
||
if d.custom_papers.len() != before {
|
||
self.save_config();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn edit_custom_draft(
|
||
d: &mut crate::ui::window::plot::PlotDialogState,
|
||
edit: impl FnOnce(&mut crate::ui::window::plot::CustomPaperDraft),
|
||
) {
|
||
if let Some(draft) = d.custom_editor.as_mut() {
|
||
edit(draft);
|
||
draft.error = None;
|
||
}
|
||
}
|
||
|
||
/// Properties… for the selected printer.
|
||
///
|
||
/// On CUPS platforms (Linux, macOS) the driver's options are listed in an
|
||
/// in-line editor and applied to every job for that printer through
|
||
/// `lp -o`. On Windows the system opens the driver's own printing
|
||
/// preferences in its own process, and the print job honours what the
|
||
/// user saves there. Without a named printer, or where the options
|
||
/// cannot be listed, the platform's printer settings open instead.
|
||
fn on_printer_properties(&mut self) -> Task<Message> {
|
||
// "Default" is a real printer: its preferences sheet is the one to
|
||
// open, not the system's printer list.
|
||
let chosen = self
|
||
.plot_dialog
|
||
.printer
|
||
.clone()
|
||
.or_else(|| self.plot_dialog.default_printer.clone());
|
||
let Some(printer) = chosen.filter(|_| !self.plot_dialog.to_file) else {
|
||
self.open_printer_settings(None);
|
||
return Task::none();
|
||
};
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
// The driver's sheet is modal: it pumps messages itself until the
|
||
// user closes it. Hosted on our event-loop thread it starves the
|
||
// loop it is sharing, so the window stops painting and a core
|
||
// spins. The system's own preferences process pumps its own
|
||
// messages instead, which leaves ours free.
|
||
self.open_printer_settings(Some(&printer));
|
||
return Task::none();
|
||
}
|
||
#[allow(unreachable_code)]
|
||
{
|
||
self.plot_dialog.printer_editor = Some(crate::ui::window::plot::PrinterOptionsDraft {
|
||
printer: printer.clone(),
|
||
options: None,
|
||
choices: Default::default(),
|
||
error: None,
|
||
});
|
||
let name = printer.clone();
|
||
background_task(
|
||
crate::t!("Printer options"),
|
||
move || crate::io::print_to_printer::printer_options(&printer),
|
||
move |result| {
|
||
Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded(
|
||
name, result,
|
||
))
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The platform's printer settings surface: the driver's printing
|
||
/// preferences for `printer`, or — without one — a settings panel on
|
||
/// Linux, the printers control panel on Windows, System Settings on macOS.
|
||
fn open_printer_settings(&mut self, printer: Option<&str>) {
|
||
match crate::io::print_to_printer::open_printer_properties(printer) {
|
||
Ok(()) => self
|
||
.command_line
|
||
.push_info(crate::t!("Opened printer properties.").as_ref()),
|
||
Err(error) => self.command_line.push_error(&error),
|
||
}
|
||
}
|
||
|
||
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, OUT_SVG, STYLE_NONE,
|
||
};
|
||
match msg {
|
||
M::Close => {
|
||
// Keep the user's last Plot choices available for the next
|
||
// invocation even when the dialog was simply closed.
|
||
self.save_config();
|
||
self.close_active_modal();
|
||
Task::none()
|
||
}
|
||
M::Printer(s) => {
|
||
// Driver choices belong to a printer; the editor closes when
|
||
// the target changes.
|
||
self.plot_dialog.printer_editor = None;
|
||
if s == OUT_PDF {
|
||
self.plot_dialog.to_file = true;
|
||
self.plot_dialog.file_svg = false;
|
||
} else if s == OUT_SVG {
|
||
self.plot_dialog.to_file = true;
|
||
self.plot_dialog.file_svg = true;
|
||
} else if s == OUT_DEFAULT {
|
||
self.plot_dialog.to_file = false;
|
||
self.plot_dialog.file_svg = false;
|
||
self.plot_dialog.printer = None;
|
||
} else {
|
||
self.plot_dialog.to_file = false;
|
||
self.plot_dialog.file_svg = false;
|
||
self.plot_dialog.printer = Some(s);
|
||
}
|
||
self.save_config();
|
||
// This is an explicit device change, so PAPERUPDATE may
|
||
// reconcile an unsupported sheet when its capabilities arrive.
|
||
self.request_printer_media(true)
|
||
}
|
||
M::PrinterMedia(printer, caps) => {
|
||
let still_current = !self.plot_dialog.to_file
|
||
&& self.plot_dialog.printer.as_deref() == Some(printer.as_str());
|
||
|
||
if still_current {
|
||
self.plot_dialog.printer_media = caps;
|
||
|
||
let reconcile = self
|
||
.plot_reconcile_printer_media
|
||
.as_deref()
|
||
.is_some_and(|pending| pending.eq_ignore_ascii_case(&printer));
|
||
|
||
if reconcile {
|
||
// Keep the explicit-device context alive while this
|
||
// printer remains selected. Capability updates for the
|
||
// same user-selected device must continue to obey
|
||
// PAPERUPDATE.
|
||
self.reconcile_sheet_with_printer(&printer);
|
||
}
|
||
}
|
||
|
||
Task::none()
|
||
}
|
||
M::CustomPaper(msg) => {
|
||
self.on_custom_paper_msg(msg);
|
||
Task::none()
|
||
}
|
||
M::PrinterProperties => self.on_printer_properties(),
|
||
M::Import(msg) => self.on_page_setup_import(msg),
|
||
M::PrinterOptionsLoaded(printer, result) => {
|
||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||
if draft.printer == printer {
|
||
match result {
|
||
Ok(options) => {
|
||
// Start from the remembered choices, then the
|
||
// driver's defaults.
|
||
let remembered = self
|
||
.plot_dialog
|
||
.driver_options
|
||
.get(&printer)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let draft = self.plot_dialog.printer_editor.as_mut().unwrap();
|
||
draft.choices = options
|
||
.iter()
|
||
.map(|option| {
|
||
let choice = remembered
|
||
.get(&option.key)
|
||
.filter(|c| {
|
||
option.choices.iter().any(|x| &x.keyword == *c)
|
||
})
|
||
.cloned()
|
||
.unwrap_or_else(|| option.default.clone());
|
||
(option.key.clone(), choice)
|
||
})
|
||
.collect();
|
||
draft.options = Some(options);
|
||
}
|
||
Err(error) => draft.error = Some(error),
|
||
}
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
M::PrinterOptionSet(key, choice) => {
|
||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||
draft.choices.insert(key, choice);
|
||
}
|
||
Task::none()
|
||
}
|
||
M::PrinterOptionsReset => {
|
||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||
if let Some(options) = &draft.options {
|
||
draft.choices = options
|
||
.iter()
|
||
.map(|option| (option.key.clone(), option.default.clone()))
|
||
.collect();
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
M::PrinterOptionsApply => {
|
||
if let Some(draft) = self.plot_dialog.printer_editor.take() {
|
||
let overrides = draft.overrides();
|
||
if overrides.is_empty() {
|
||
self.plot_dialog.driver_options.remove(&draft.printer);
|
||
} else {
|
||
self.plot_dialog
|
||
.driver_options
|
||
.insert(draft.printer.clone(), overrides);
|
||
}
|
||
self.save_config();
|
||
}
|
||
Task::none()
|
||
}
|
||
M::PrinterOptionsCancel => {
|
||
self.plot_dialog.printer_editor = None;
|
||
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;
|
||
self.refresh_custom_scale_fields();
|
||
}
|
||
Task::none()
|
||
}
|
||
M::PaperUnits(s) => {
|
||
use crate::io::paper_catalog::PaperUnits;
|
||
let units = if s == "Inches" {
|
||
PaperUnits::Inches
|
||
} else {
|
||
PaperUnits::Millimeters
|
||
};
|
||
let d = &mut self.plot_dialog;
|
||
if units != d.paper_units {
|
||
// The offsets are physical distances: re-spell them in the
|
||
// new unit. The scale converts with the unit on its own.
|
||
let convert = |text: &str| {
|
||
text.parse::<f64>()
|
||
.map(|value| units.from_mm(d.paper_units.to_mm(value)))
|
||
.map(|value| trim_decimals(&format!("{value:.6}")))
|
||
.unwrap_or_else(|_| text.to_string())
|
||
};
|
||
d.offset_x = convert(&d.offset_x);
|
||
d.offset_y = convert(&d.offset_y);
|
||
d.paper_units = units;
|
||
self.refresh_custom_scale_fields();
|
||
}
|
||
Task::none()
|
||
}
|
||
M::CustomScalePaper(s) => {
|
||
self.plot_dialog.custom_scale_paper = s;
|
||
self.adopt_custom_scale();
|
||
Task::none()
|
||
}
|
||
M::CustomScaleDrawing(s) => {
|
||
self.plot_dialog.custom_scale_drawing = s;
|
||
self.adopt_custom_scale();
|
||
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,
|
||
PlotFlag::HidePaperspace => d.hide_paperspace = !d.hide_paperspace,
|
||
PlotFlag::SaveToLayout => d.save_to_layout = !d.save_to_layout,
|
||
_ => {}
|
||
}
|
||
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;
|
||
self.plot_dialog.style_error = None;
|
||
} 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.plot_dialog.style_error = None;
|
||
self.report_plot_style_warnings(&table);
|
||
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);
|
||
self.plot_dialog.style_error = Some(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) => {
|
||
// Print All Options edits the page setups represented by the
|
||
// layout rows. With "Save changes to layout" enabled, preserve
|
||
// the setup we are leaving before another one is loaded.
|
||
if self.print_all_options
|
||
&& self.plot_dialog.save_to_layout
|
||
&& self.plot_dialog.selected_setup != name
|
||
{
|
||
let previous = self.plot_dialog.selected_setup.clone();
|
||
|
||
if is_layout_entry(&previous) {
|
||
let layout = layout_entry_name(&previous).to_string();
|
||
self.apply_dialog_to_named_layout(&layout);
|
||
} else if previous == crate::ui::window::plot::SETUP_PREV {
|
||
// `<previous>` is a transient snapshot, not an actual
|
||
// page setup. `plot_prev_context` records which layout
|
||
// that snapshot came from.
|
||
//
|
||
// If the user returns to that same layout, commit the
|
||
// restored snapshot to it before reloading the row.
|
||
if let Some((tab, layout)) = self.plot_prev_context.clone() {
|
||
let target = format!("*{layout}*");
|
||
|
||
if tab == self.active_tab && name == target {
|
||
self.apply_dialog_to_named_layout(&layout);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
self.select_page_setup(&name);
|
||
|
||
if self.print_all_options {
|
||
self.plot_dialog.paper_space = true;
|
||
self.plot_dialog.area = "Layout".into();
|
||
}
|
||
|
||
// The setup may name a different printer.
|
||
self.request_printer_media(true)
|
||
}
|
||
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),
|
||
}
|
||
}
|
||
|
||
pub(super) 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.
|
||
pub(super) 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.file_svg = false;
|
||
let a4 = crate::io::paper_catalog::default_paper();
|
||
d.paper = a4.canonical.to_string();
|
||
d.orientation = "Landscape".into();
|
||
(d.paper_width_mm, d.paper_height_mm) =
|
||
a4.sheet_mm(crate::io::paper_catalog::Orientation::Landscape);
|
||
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.paper_units = crate::io::paper_catalog::PaperUnits::Millimeters;
|
||
d.upside_down = false;
|
||
d.hide_paperspace = 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());
|
||
self.refresh_custom_scale_fields();
|
||
} 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).
|
||
/// The dialog's values as a named page setup.
|
||
fn dialog_to_plotsettings(&self) -> codec::objects::PlotSettings {
|
||
self.plot_settings_from_dialog(self.dialog_base_settings())
|
||
}
|
||
|
||
/// Load a `PlotSettings` into the dialog editor fields.
|
||
pub(in crate::app) fn load_plotsettings_into_dialog(
|
||
&mut self,
|
||
ps: &codec::objects::PlotSettings,
|
||
) {
|
||
use crate::io::paper_catalog::PaperUnits;
|
||
use codec::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,
|
||
));
|
||
self.plot_dialog.window = self.plot_window;
|
||
}
|
||
let mut style_error = None;
|
||
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))
|
||
{
|
||
match crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) {
|
||
Ok(table) => {
|
||
self.report_plot_style_warnings(&table);
|
||
self.active_plot_style = Some(table);
|
||
}
|
||
Err(error) => style_error = Some(error),
|
||
}
|
||
}
|
||
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));
|
||
// The stored media name wins (it is what the source application wrote); a drawing
|
||
// without a usable name is matched by size, and an unknown size keeps
|
||
// its own dimensions as a custom sheet. The sheet's orientation is the
|
||
// stored width/height order; the rotation below may flip it again.
|
||
let paper =
|
||
crate::io::paper_catalog::from_drawing(&ps.paper_size, ps.paper_width, ps.paper_height);
|
||
let orient = if ps.paper_width >= ps.paper_height {
|
||
"Landscape"
|
||
} else {
|
||
"Portrait"
|
||
}
|
||
.to_string();
|
||
let family_unit_mm = self.tabs[self.active_tab].scene.scale_family_unit_mm();
|
||
let d = &mut self.plot_dialog;
|
||
d.paper = paper.canonical.to_string();
|
||
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.paper_units = match ps.paper_units {
|
||
codec::objects::PlotPaperUnits::Inches => PaperUnits::Inches,
|
||
_ => PaperUnits::Millimeters,
|
||
};
|
||
d.offset_x = trim_decimals(&format!("{:.4}", d.paper_units.from_mm(ps.origin_x)));
|
||
d.offset_y = trim_decimals(&format!("{:.4}", d.paper_units.from_mm(ps.origin_y)));
|
||
d.hide_paperspace = ps.flags.plot_hidden;
|
||
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 ratio = ps.scale_numerator / ps.scale_denominator;
|
||
let stored_factor = if ratio.is_finite() && ratio > 0.0 {
|
||
ratio
|
||
} else if ps.flags.use_standard_scale
|
||
&& ps.standard_scale_factor.is_finite()
|
||
&& ps.standard_scale_factor > 0.0
|
||
{
|
||
ratio_from_stored_scale_factor(ps.standard_scale_factor, ps.paper_units)
|
||
} else {
|
||
ps.scale_type.scale_factor()
|
||
};
|
||
// The file's factor is per the layout's paper unit; the picker's are
|
||
// per the drawing's scale-family unit (see `plot_settings_from_dialog`).
|
||
let target_factor = if stored_factor.is_finite() && stored_factor > 0.0 {
|
||
stored_factor * plot_paper_unit_mm(ps.paper_units) / family_unit_mm
|
||
} else {
|
||
1.0
|
||
};
|
||
if !d.fit_to_paper {
|
||
d.scale = scale_name_for_factor(&d.scales, target_factor).unwrap_or_else(|| {
|
||
// A scale the drawing has no name for stays what it is rather
|
||
// than snapping to 1:1; the ratio entry disappears again with
|
||
// the next scale-list refresh once it is no longer selected.
|
||
let name = ratio_scale_name(target_factor);
|
||
d.scales.push((name.clone(), target_factor));
|
||
d.scales
|
||
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||
name
|
||
});
|
||
} else if !d.scales.iter().any(|(name, _)| name == &d.scale) {
|
||
// A fitted plot's stored ratio is what the fit computed, not a
|
||
// choice; the picker just needs a valid entry behind it.
|
||
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());
|
||
}
|
||
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();
|
||
match crate::io::plot_device::PlotDevice::from_stored_name(&ps.printer_name).0 {
|
||
crate::io::plot_device::PlotDevice::Pdf => {
|
||
d.to_file = true;
|
||
d.file_svg = false;
|
||
d.printer = None;
|
||
}
|
||
crate::io::plot_device::PlotDevice::Printer(name) => {
|
||
d.to_file = false;
|
||
d.file_svg = false;
|
||
d.printer = Some(name);
|
||
}
|
||
crate::io::plot_device::PlotDevice::None => {
|
||
d.to_file = false;
|
||
d.file_svg = false;
|
||
d.printer = None;
|
||
}
|
||
}
|
||
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;
|
||
d.style_error = if d.style_missing { style_error } else { None };
|
||
self.refresh_custom_scale_fields();
|
||
}
|
||
|
||
/// Tell the user what a plot style table's loader had to tolerate.
|
||
pub(in crate::app) fn report_plot_style_warnings(
|
||
&mut self,
|
||
table: &crate::io::plot_style::PlotStyleTable,
|
||
) {
|
||
for warning in &table.load_warnings {
|
||
let name = &table.name;
|
||
self.command_line
|
||
.push_warning(&format!("{name}: {warning}"));
|
||
}
|
||
}
|
||
|
||
/// Copy transient paper/scale choices out of the dialog without changing
|
||
/// the active layout.
|
||
fn sync_dialog_plot_runtime(&mut self) {
|
||
let d = &self.plot_dialog;
|
||
self.plot_paper = plot_dialog_paper(d);
|
||
self.plot_orientation = plot_dialog_orientation(d);
|
||
}
|
||
|
||
fn apply_dialog_to_layout(&mut self) {
|
||
self.sync_dialog_plot_runtime();
|
||
self.apply_plot_page_settings();
|
||
}
|
||
|
||
/// Persist the current Plot editor into a specific layout.
|
||
///
|
||
/// This is needed by Print All Options because selecting `*Layout1*`,
|
||
/// `*Layout2*`, etc. does not switch `scene.current_layout`.
|
||
fn apply_dialog_to_named_layout(&mut self, layout_name: &str) {
|
||
use codec::objects::{PlotPaperUnits, PlotSettings};
|
||
|
||
let i = self.active_tab;
|
||
|
||
let base = self.tabs[i]
|
||
.scene
|
||
.plot_settings_for(layout_name)
|
||
.unwrap_or_else(|| {
|
||
let mut settings = PlotSettings::new("");
|
||
settings.paper_units = PlotPaperUnits::Millimeters;
|
||
settings
|
||
});
|
||
|
||
let ps = self.plot_settings_from_dialog(base);
|
||
|
||
if self.tabs[i]
|
||
.scene
|
||
.set_layout_plot_settings(layout_name, &ps)
|
||
{
|
||
self.tabs[i].dirty = true;
|
||
|
||
if self.tabs[i].scene.current_layout == layout_name {
|
||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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();
|
||
// The web dialog only offers PDF output, but a page setup loaded from
|
||
// the drawing can still seed `to_file = false` — always export there.
|
||
#[cfg(target_arch = "wasm32")]
|
||
let to_file = true;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let to_file = d.to_file;
|
||
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();
|
||
// A preview never touches the layout; a sent plot writes the dialog
|
||
// into it when "Save changes to layout" is on, so the page setup
|
||
// remembers the last plot the way other applications expect. The
|
||
// runtime paper/scale choices drive this one plot operation either way.
|
||
if !preview && d.save_to_layout {
|
||
self.apply_dialog_to_layout();
|
||
} else {
|
||
self.sync_dialog_plot_runtime();
|
||
}
|
||
self.active_modal = None;
|
||
self.reset_modal_geometry();
|
||
if !preview {
|
||
self.stamp_plot_fields();
|
||
}
|
||
|
||
// 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(page) = 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 stem = self.active_stem("");
|
||
let work = move || {
|
||
crate::io::print_to_printer::preview_page_pdf(&stem, &page)
|
||
.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 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 opts = self.plot_print_options(&d);
|
||
let work = move || {
|
||
crate::io::pdf_export::export_pdf(&page, &tmp)
|
||
.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 page = self.layout_plot_page();
|
||
|
||
if preview {
|
||
let stem = self.active_stem("");
|
||
let work = move || {
|
||
crate::io::print_to_printer::preview_page_pdf(&stem, &page)
|
||
.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 to_file {
|
||
// Reuse the tested PDF export flow (it opens a save dialog).
|
||
return Task::done(Message::PlotExport);
|
||
}
|
||
|
||
let opts = self.plot_print_options(&d);
|
||
self.command_line
|
||
.push_info(crate::t!("Sending to system printer…").as_ref());
|
||
let work = move || {
|
||
iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with(
|
||
page, 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,
|
||
) -> 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()),
|
||
driver_options: d
|
||
.printer
|
||
.as_ref()
|
||
.and_then(|printer| d.driver_options.get(printer))
|
||
.map(|options| {
|
||
options
|
||
.iter()
|
||
.map(|(k, v)| (k.clone(), v.clone()))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default(),
|
||
}
|
||
}
|
||
|
||
fn pdf_plot_options(
|
||
d: &crate::ui::window::plot::PlotDialogState,
|
||
) -> crate::io::pdf_export::PdfPlotOptions {
|
||
crate::io::pdf_export::PdfPlotOptions {
|
||
object_lineweights: d.lineweights,
|
||
scale_lineweights: d.scale_lw && !d.fit_to_paper,
|
||
// PLOTTRANSPARENCYOVERRIDE: 0 never, 2 always, 1 as the dialog says.
|
||
transparency: match d.transparency_override {
|
||
0 => false,
|
||
2 => true,
|
||
_ => d.transparency,
|
||
},
|
||
stamp: d.stamp,
|
||
merge_lines: d.merge_lines,
|
||
quality: crate::io::pdf_export::PlotQuality::from_dialog(&d.quality),
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Fast path: the selected table is already active.
|
||
if let Some(table) = self
|
||
.active_plot_style
|
||
.as_ref()
|
||
.filter(|table| table.name.eq_ignore_ascii_case(&d.style_name))
|
||
{
|
||
return Some(table.clone());
|
||
}
|
||
|
||
// The dialog selection is persisted separately from the parsed CTB
|
||
// object. After reopening a drawing the name may be correct while the
|
||
// in-memory table is still the previous/default one. Plotting must not
|
||
// silently ignore the selected CTB in that state.
|
||
crate::io::plot_style::PlotStyleTable::load_named(&d.style_name).ok()
|
||
}
|
||
|
||
pub(in crate::app) fn window_plot_job(&self) -> Option<PdfPageInput> {
|
||
self.area_plot_job(self.plot_window?)
|
||
}
|
||
|
||
pub(in crate::app) fn display_plot_job(&self) -> Option<PdfPageInput> {
|
||
self.area_plot_job(self.display_plot_window()?)
|
||
}
|
||
|
||
pub(in crate::app) fn limits_plot_job(&self) -> Option<PdfPageInput> {
|
||
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<PdfPageInput> {
|
||
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(())?;
|
||
let center = crate::scene::named_view_center(view);
|
||
let (cx, cy) = (center.x, center.y);
|
||
self.area_plot_job((cx - half_w, cy - half_h, cx + half_w, cy + half_h))
|
||
}
|
||
|
||
pub(in crate::app) fn extents_plot_job(&self) -> Option<PdfPageInput> {
|
||
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 content = plot_scene_content(
|
||
scene,
|
||
self.plot_dialog.paperspace_last,
|
||
plot_render_mode_override(&self.plot_dialog),
|
||
);
|
||
let extents = plot_content_extents(&content)?;
|
||
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().view.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<PdfPageInput> {
|
||
use crate::io::paper_catalog::{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 (origin_x, origin_y) = self.dialog_plot_origin_mm();
|
||
let target_x = if self.plot_dialog.center {
|
||
centered_x
|
||
} else {
|
||
origin_x
|
||
};
|
||
let target_y = if self.plot_dialog.center {
|
||
centered_y
|
||
} else {
|
||
origin_y
|
||
};
|
||
let scene = &self.tabs[i].scene;
|
||
let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32);
|
||
let mut content = plot_scene_content(
|
||
scene,
|
||
self.plot_dialog.paperspace_last,
|
||
plot_render_mode_override(&self.plot_dialog),
|
||
);
|
||
let in_window = |wire: &crate::io::pdf_export::PlotWire| {
|
||
wire.name != "paper_printable_area"
|
||
&& wire.aabb[0] <= wx1
|
||
&& wire.aabb[2] >= wx0
|
||
&& wire.aabb[1] <= wy1
|
||
&& wire.aabb[3] >= wy0
|
||
};
|
||
content.group_splits.wires = content.wires
|
||
[..content.group_splits.wires.min(content.wires.len())]
|
||
.iter()
|
||
.filter(|wire| in_window(wire))
|
||
.count();
|
||
std::sync::Arc::make_mut(&mut content.wires).retain(in_window);
|
||
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(PdfPageInput {
|
||
content,
|
||
paper_w: page_w,
|
||
paper_h: page_h,
|
||
offset_x,
|
||
offset_y,
|
||
rotation_deg: rotation,
|
||
scale: scale as f32,
|
||
clip,
|
||
options: Self::pdf_plot_options(&self.plot_dialog),
|
||
plot_style: self.dialog_plot_style(&self.plot_dialog),
|
||
})
|
||
}
|
||
|
||
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,
|
||
)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod plot_paper_tests {
|
||
use crate::app::{Message, OpenCADStudio};
|
||
use crate::ui::window::plot::PlotDlgMsg;
|
||
|
||
/// A drawing on `Layout1` whose page setup names its sheet the way a
|
||
/// Windows printer driver does — a bare `A4` with landscape dimensions.
|
||
fn app_with_printer_named_sheet() -> OpenCADStudio {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.automation_op(r#"{"op":"new"}"#);
|
||
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
|
||
let i = app.active_tab;
|
||
// Millimetre drawing units, so a ratio plots as written; the metre
|
||
// case has its own test.
|
||
app.tabs[i].scene.document.header.insertion_units = 4;
|
||
let mut ps = app.tabs[i]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.expect("a fresh layout carries plot settings");
|
||
ps.paper_size = "A4".into();
|
||
ps.paper_width = 297.0;
|
||
ps.paper_height = 210.0;
|
||
// Such drivers spell the orientation in the dimensions, not the rotation.
|
||
ps.rotation = codec::objects::PlotRotation::None;
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
app
|
||
}
|
||
|
||
fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) {
|
||
let ps = layout_settings(app);
|
||
(ps.paper_size, ps.paper_width, ps.paper_height)
|
||
}
|
||
|
||
fn layout_settings(app: &OpenCADStudio) -> codec::objects::PlotSettings {
|
||
app.tabs[app.active_tab]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.unwrap()
|
||
}
|
||
|
||
fn layout_rotation(app: &OpenCADStudio) -> codec::objects::PlotRotation {
|
||
layout_settings(app).rotation
|
||
}
|
||
|
||
/// Store the drawing's plot settings for `Layout1` after `edit` touched them.
|
||
fn edit_layout_settings(
|
||
app: &mut OpenCADStudio,
|
||
edit: impl FnOnce(&mut codec::objects::PlotSettings),
|
||
) {
|
||
let i = app.active_tab;
|
||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
edit(&mut ps);
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_drawing_starts_with_a_compatible_default_layout() {
|
||
use codec::objects::{PlotRotation, PlotType, ScaledType};
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.automation_op(r#"{"op":"new"}"#);
|
||
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.paper_size, "ISO_A4_(210.00_x_297.00_MM)");
|
||
assert_eq!((ps.paper_width, ps.paper_height), (210.0, 297.0));
|
||
assert_eq!(ps.rotation, PlotRotation::Degrees90);
|
||
assert_eq!(ps.printer_name, "none_device");
|
||
assert_eq!(ps.plot_type, PlotType::Layout);
|
||
assert_eq!(ps.scale_type, ScaledType::OneToOne);
|
||
assert!(ps.flags.use_standard_scale && ps.flags.print_lineweights);
|
||
// The sheet on screen is the landscape A4 the rotation describes.
|
||
let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
|
||
assert_eq!((x1 - x0, y1 - y0), (297.0, 210.0));
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.automation_op(r#"{"op":"new"}"#);
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.orientation, "Landscape");
|
||
assert!(!app.plot_dialog.upside_down);
|
||
}
|
||
|
||
#[test]
|
||
fn orientation_is_stored_as_a_rotation_of_the_medium() {
|
||
use crate::ui::window::plot::PlotFlag;
|
||
use codec::objects::PlotRotation as R;
|
||
// A newly picked sheet is the catalogue's portrait medium…
|
||
let portrait_medium = [
|
||
("Portrait", false, R::None),
|
||
("Landscape", false, R::Degrees90),
|
||
("Portrait", true, R::Degrees180),
|
||
("Landscape", true, R::Degrees270),
|
||
];
|
||
// …while the drawing's own landscape `A4` medium is kept, so the
|
||
// same choices rotate relative to it.
|
||
let landscape_medium = [
|
||
("Landscape", false, R::None),
|
||
("Portrait", false, R::Degrees90),
|
||
("Landscape", true, R::Degrees180),
|
||
("Portrait", true, R::Degrees270),
|
||
];
|
||
for (pick_a3, cases) in [(true, portrait_medium), (false, landscape_medium)] {
|
||
for (orientation, upside_down, rotation) in cases {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
if pick_a3 {
|
||
let a3 = "ISO_A3_(297.00_x_420.00_MM)";
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper(a3.into()));
|
||
}
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Orientation(orientation.into()));
|
||
if upside_down {
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::UpsideDown));
|
||
}
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
let medium = if pick_a3 {
|
||
(297.0, 420.0)
|
||
} else {
|
||
(297.0, 210.0)
|
||
};
|
||
assert_eq!((ps.paper_width, ps.paper_height), medium, "{orientation}");
|
||
assert_eq!(
|
||
ps.rotation, rotation,
|
||
"{orientation} upside-down={upside_down}"
|
||
);
|
||
// The next dialog reads the same choices back out of the rotation.
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.orientation, orientation);
|
||
assert_eq!(app.plot_dialog.upside_down, upside_down);
|
||
let sheet = super::plot_dialog_sheet_mm(&app.plot_dialog);
|
||
let long_side = medium.0.max(medium.1);
|
||
assert_eq!(sheet.0 > sheet.1, orientation == "Landscape", "{sheet:?}");
|
||
assert_eq!(sheet.0.max(sheet.1), long_side);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn standard_scales_are_written_by_code_and_others_as_ratios() {
|
||
use codec::objects::ScaledType;
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(
|
||
crate::ui::window::plot::PlotFlag::FitToPaper,
|
||
));
|
||
assert!(!app.plot_dialog.fit_to_paper);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.scale_type, ScaledType::OneToHundred);
|
||
assert!(
|
||
!ps.flags.use_standard_scale,
|
||
"the bit stays clear, as the source application writes it"
|
||
);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 100.0));
|
||
// The stored factor follows the millimetre convention: ratio × 25.4.
|
||
assert!((ps.standard_scale_factor - 0.254).abs() < 1e-12);
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.scale, "1:100");
|
||
// A ratio the format has no code for is a custom scale, spelled as the ratio.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:3".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.scale_type, ScaledType::CustomScale);
|
||
assert!(!ps.flags.use_standard_scale);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0));
|
||
// …and survives a reopen even though the drawing's scale list lacks it.
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.scale, "1:3");
|
||
assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 1.0 / 3.0).abs() < 1e-12);
|
||
// Fit to paper is its own scale type and carries the bit; a Layout
|
||
// plot goes back to the 1:1 the dialog holds it at.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(
|
||
crate::ui::window::plot::PlotFlag::FitToPaper,
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.scale_type, ScaledType::ScaleToFit);
|
||
assert!(ps.flags.use_standard_scale);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.scale_type, ScaledType::OneToOne);
|
||
assert!(!ps.flags.use_standard_scale);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 1.0));
|
||
assert!((ps.standard_scale_factor - 25.4).abs() < 1e-12);
|
||
}
|
||
|
||
#[test]
|
||
fn a_metre_drawing_stores_the_millimetres_it_really_plots_per_unit() {
|
||
use codec::objects::ScaledType;
|
||
let mut app = app_with_printer_named_sheet();
|
||
// Insertion units: metres. "1:100" then plots 10 mm per drawing unit.
|
||
app.tabs[app.active_tab]
|
||
.scene
|
||
.document
|
||
.header
|
||
.insertion_units = 6;
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(
|
||
crate::ui::window::plot::PlotFlag::FitToPaper,
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
|
||
assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 10.0).abs() < 1e-9);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(
|
||
ps.scale_type,
|
||
ScaledType::CustomScale,
|
||
"not the format's 1:100"
|
||
);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (10.0, 1.0));
|
||
assert!((ps.standard_scale_factor - 254.0).abs() < 1e-9);
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.scale, "1:100");
|
||
}
|
||
|
||
#[test]
|
||
fn an_inch_layout_keeps_its_unit_and_converts_the_scale() {
|
||
use codec::objects::{PlotPaperUnits, ScaledType};
|
||
let mut app = app_with_printer_named_sheet();
|
||
edit_layout_settings(&mut app, |ps| ps.paper_units = PlotPaperUnits::Inches);
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(
|
||
crate::ui::window::plot::PlotFlag::FitToPaper,
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(
|
||
ps.paper_units,
|
||
PlotPaperUnits::Inches,
|
||
"the layout's unit is not ours to change"
|
||
);
|
||
// 1 mm = 100 units is 1 inch = 2540 units on an inch page setup; the
|
||
// standard code still names the choice, as the source application does.
|
||
assert_eq!(ps.scale_type, ScaledType::OneToHundred);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0));
|
||
assert!((ps.standard_scale_factor - 1.0 / 2540.0).abs() < 1e-15);
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.scale, "1:100");
|
||
// A layout plot keeps the paper space in millimetres: "1:1" is
|
||
// `1 in = 25.4 units`.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.scale_type, ScaledType::OneToOne);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4));
|
||
assert!((ps.standard_scale_factor - 1.0 / 25.4).abs() < 1e-15);
|
||
}
|
||
|
||
#[test]
|
||
fn a_named_setup_and_the_layout_settings_are_written_alike() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(
|
||
crate::ui::window::plot::PlotFlag::FitToPaper,
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:50".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(
|
||
crate::ui::window::plot::PlotFlag::UpsideDown,
|
||
));
|
||
let named = app.dialog_to_plotsettings();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let layout = layout_settings(&app);
|
||
assert_eq!(layout.paper_size, named.paper_size);
|
||
assert_eq!(layout.printer_name, named.printer_name);
|
||
assert_eq!(layout.margins, named.margins);
|
||
assert_eq!(
|
||
(layout.paper_width, layout.paper_height),
|
||
(named.paper_width, named.paper_height)
|
||
);
|
||
assert_eq!(layout.rotation, named.rotation);
|
||
assert_eq!(layout.plot_type, named.plot_type);
|
||
assert_eq!(
|
||
(layout.origin_x, layout.origin_y),
|
||
(named.origin_x, named.origin_y)
|
||
);
|
||
assert_eq!(layout.scale_type, named.scale_type);
|
||
assert_eq!(
|
||
(layout.scale_numerator, layout.scale_denominator),
|
||
(named.scale_numerator, named.scale_denominator)
|
||
);
|
||
assert_eq!(layout.standard_scale_factor, named.standard_scale_factor);
|
||
assert_eq!(layout.flags, named.flags);
|
||
assert_eq!(layout.shade_plot_mode, named.shade_plot_mode);
|
||
assert_eq!(layout.shade_plot_resolution, named.shade_plot_resolution);
|
||
assert_eq!(named.rotation, codec::objects::PlotRotation::Degrees270);
|
||
assert_eq!(named.scale_type, codec::objects::ScaledType::OneToFifty);
|
||
}
|
||
|
||
#[test]
|
||
fn flags_the_dialog_does_not_edit_survive_a_round_trip() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
edit_layout_settings(&mut app, |ps| {
|
||
ps.flags.plot_viewport_borders = true;
|
||
ps.flags.update_paper = true;
|
||
ps.flags.zoom_to_paper_on_update = true;
|
||
ps.flags.unknown_bits = 0x8000;
|
||
ps.paper_image_origin_x = 3.5;
|
||
});
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert!(ps.flags.plot_viewport_borders);
|
||
assert!(ps.flags.update_paper && ps.flags.zoom_to_paper_on_update);
|
||
assert_eq!(ps.flags.unknown_bits, 0x8000);
|
||
assert_eq!(ps.paper_image_origin_x, 3.5);
|
||
}
|
||
|
||
/// Open the metric page-setup fixture written by the commercial
|
||
/// application (see `tests/fixtures/plot/README.md`).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn app_with_fixture() -> OpenCADStudio {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let path = concat!(
|
||
env!("CARGO_MANIFEST_DIR"),
|
||
"/tests/fixtures/plot/page-setups-metric.dxf"
|
||
);
|
||
// json! escapes the path: a Windows CARGO_MANIFEST_DIR carries
|
||
// backslashes, and a raw format! would put invalid \X escapes in the
|
||
// JSON string.
|
||
let reply = app.automation_op(&serde_json::json!({"op": "open", "path": path}).to_string());
|
||
assert_eq!(reply["ok"], true, "{reply}");
|
||
app
|
||
}
|
||
|
||
/// The page-setup fields the file format stores, minus handles.
|
||
fn stored_fields(ps: &codec::objects::PlotSettings) -> String {
|
||
format!(
|
||
"{} | {} | {:?} | {:.6} {:.6} | {:.6} {:.6} {:.6} {:.6} | {:.6} {:.6} | {} {} \
|
||
| {:.6} / {:.6} | {:.9} | {:?} | {:?} | {:?} {:?} {}",
|
||
ps.printer_name,
|
||
ps.paper_size,
|
||
ps.paper_units,
|
||
ps.paper_width,
|
||
ps.paper_height,
|
||
ps.margins.left,
|
||
ps.margins.bottom,
|
||
ps.margins.right,
|
||
ps.margins.top,
|
||
ps.origin_x,
|
||
ps.origin_y,
|
||
ps.rotation.to_code(),
|
||
ps.plot_type.to_code(),
|
||
ps.scale_numerator,
|
||
ps.scale_denominator,
|
||
ps.standard_scale_factor,
|
||
ps.scale_type,
|
||
ps.flags,
|
||
ps.shade_plot_mode,
|
||
ps.shade_plot_resolution,
|
||
ps.shade_plot_dpi
|
||
)
|
||
}
|
||
|
||
/// Every layout of the fixture survives "open the dialog, apply" without
|
||
/// a single stored field changing — the dialog reads the source
|
||
/// application's conventions and writes them back the same way.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn fixture_page_setups_round_trip_through_the_dialog_unchanged() {
|
||
use codec::objects::{PlotRotation, ScaledType};
|
||
let mut app = app_with_fixture();
|
||
let a3_medium = (297.0106506347656, 419.9889831542968);
|
||
let arch_d_medium = (914.4000244140626, 609.5999755859375);
|
||
let expectations = [
|
||
("A4 1-100", PlotRotation::None, (297.0, 210.0), true),
|
||
("A3 - plotter", PlotRotation::Degrees90, a3_medium, false),
|
||
("ARCH D - PLOTTER", PlotRotation::None, arch_d_medium, true),
|
||
];
|
||
for (layout, rotation, medium, pdf) in expectations {
|
||
let orientation = "Landscape";
|
||
let _ = app.update(Message::LayoutSwitch(layout.into()));
|
||
let i = app.active_tab;
|
||
let before = app.tabs[i].scene.plot_settings_for(layout).unwrap();
|
||
assert_eq!(before.rotation, rotation, "{layout}");
|
||
assert_eq!(
|
||
(before.paper_width, before.paper_height),
|
||
medium,
|
||
"{layout}"
|
||
);
|
||
assert_eq!(before.scale_type, ScaledType::OneToOne, "{layout}");
|
||
let _ = app.on_plot_dialog_open();
|
||
let d = &app.plot_dialog;
|
||
assert_eq!(d.orientation, orientation, "{layout}");
|
||
assert!(!d.upside_down);
|
||
assert_eq!(d.area, "Layout");
|
||
assert_eq!(d.scale, "1:1", "{layout}: a 1:1 plot in either paper unit");
|
||
assert_eq!(d.to_file, pdf, "{layout}");
|
||
assert_eq!(
|
||
super::plot_dialog_sheet_mm(d).0 > super::plot_dialog_sheet_mm(d).1,
|
||
true
|
||
);
|
||
assert!(
|
||
d.lineweights && d.apply_plot_styles && d.paperspace_last,
|
||
"{layout}"
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let after = app.tabs[i].scene.plot_settings_for(layout).unwrap();
|
||
assert_eq!(stored_fields(&after), stored_fields(&before), "{layout}");
|
||
}
|
||
// The system printer's sheet keeps its driver's margins and lands on
|
||
// the layout the way the source application places it: the medium's
|
||
// top margin on the left, its left margin at the bottom.
|
||
let _ = app.update(Message::LayoutSwitch("A3 - plotter".into()));
|
||
let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
|
||
assert!((x0 + 20.02365112304687).abs() < 1e-9 && (y0 + 4.995333194732666).abs() < 1e-9);
|
||
assert!((x1 - 399.96533203125).abs() < 1e-6 && (y1 - 292.015317440033).abs() < 1e-6);
|
||
}
|
||
|
||
/// Picking a new sheet on the fixture's PDF layout writes our canonical
|
||
/// portrait medium with the orientation in the rotation and the PDF
|
||
/// driver's margins for that medium.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn fixture_pdf_layout_takes_a_new_sheet_in_canonical_form() {
|
||
use codec::objects::{PaperMargin, PlotRotation};
|
||
let mut app = app_with_fixture();
|
||
let _ = app.update(Message::LayoutSwitch("A4 1-100".into()));
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[app.active_tab]
|
||
.scene
|
||
.plot_settings_for("A4 1-100")
|
||
.unwrap();
|
||
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
|
||
assert_eq!(ps.paper_size, "ISO_A3_(297.00_x_420.00_MM)");
|
||
assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0));
|
||
assert_eq!(ps.rotation, PlotRotation::Degrees90);
|
||
assert_eq!(ps.margins, PaperMargin::new(5.0, 17.0, 6.0, 18.0));
|
||
// The sheet sits at minus its displayed margins: top on the left,
|
||
// left at the bottom.
|
||
let ((x0, y0), _) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
|
||
assert!(
|
||
(x0 + 18.0).abs() < 1e-9 && (y0 + 5.0).abs() < 1e-9,
|
||
"{x0} {y0}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hide_paperspace_objects_is_its_own_flag() {
|
||
use crate::ui::window::plot::PlotFlag;
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
assert!(!app.plot_dialog.hide_paperspace);
|
||
// Shade plot "Hidden Line" is a shade mode, not this flag.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Shade("Hidden Line".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
assert!(!layout_settings(&app).flags.plot_hidden);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::HidePaperspace));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
assert!(layout_settings(&app).flags.plot_hidden);
|
||
let _ = app.on_plot_dialog_open();
|
||
assert!(app.plot_dialog.hide_paperspace);
|
||
}
|
||
|
||
#[test]
|
||
fn switching_the_page_setup_unit_respells_offsets_and_keeps_the_output() {
|
||
use crate::ui::window::plot::PlotFlag;
|
||
use codec::objects::{PlotPaperUnits, ScaledType};
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let sheet_before = app.tabs[app.active_tab].scene.paper_limits().unwrap();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::FitToPaper));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::Center));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::OffsetX("25.4".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into()));
|
||
let d = &app.plot_dialog;
|
||
assert_eq!(d.offset_x, "1", "25.4 mm is 1 inch");
|
||
assert_eq!(
|
||
(
|
||
d.custom_scale_paper.as_str(),
|
||
d.custom_scale_drawing.as_str()
|
||
),
|
||
("1", "2540")
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.paper_units, PlotPaperUnits::Inches);
|
||
assert!(
|
||
(ps.origin_x - 25.4).abs() < 1e-9,
|
||
"the file keeps millimetres"
|
||
);
|
||
assert_eq!(ps.scale_type, ScaledType::OneToHundred);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0));
|
||
// Back to millimetres: the same physical setup, spelled in mm.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Millimeters".into()));
|
||
assert_eq!(app.plot_dialog.offset_x, "25.4");
|
||
assert_eq!(app.plot_dialog.custom_scale_drawing, "100");
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
// An inch page setup over the millimetre paper space leaves the
|
||
// sheet where it was.
|
||
let ps = layout_settings(&app);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4));
|
||
let sheet_after = app.tabs[app.active_tab].scene.paper_limits().unwrap();
|
||
let size = |((x0, y0), (x1, y1)): ((f64, f64), (f64, f64))| (x1 - x0, y1 - y0);
|
||
let (before, after) = (size(sheet_before), size(sheet_after));
|
||
assert!((before.0 - after.0).abs() < 1e-9 && (before.1 - after.1).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_scale_fields_follow_and_drive_the_picker() {
|
||
use crate::ui::window::plot::PlotFlag;
|
||
use codec::objects::ScaledType;
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::FitToPaper));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
|
||
let d = &app.plot_dialog;
|
||
assert_eq!(
|
||
(
|
||
d.custom_scale_paper.as_str(),
|
||
d.custom_scale_drawing.as_str()
|
||
),
|
||
("1", "100")
|
||
);
|
||
// A typed ratio the list knows is picked under its name…
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScalePaper("2".into()));
|
||
assert_eq!(app.plot_dialog.scale, "1:50");
|
||
// …one it does not know becomes a ratio entry of its own.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("6".into()));
|
||
assert_eq!(app.plot_dialog.scale, "1:3");
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = layout_settings(&app);
|
||
assert_eq!(ps.scale_type, ScaledType::CustomScale);
|
||
assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0));
|
||
// Half-typed or nonsense values leave the scale alone.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("".into()));
|
||
assert_eq!(app.plot_dialog.scale, "1:3");
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("-4".into()));
|
||
assert_eq!(app.plot_dialog.scale, "1:3");
|
||
}
|
||
|
||
#[test]
|
||
fn the_plot_origin_counts_from_the_printable_corner_unless_plotoffset_says_edge() {
|
||
use crate::ui::window::plot::PlotFlag;
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::Center));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::OffsetX("10".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::OffsetY("20".into()));
|
||
// The PDF driver's margins on the drawing's landscape A4 medium:
|
||
// 5 left, 17 bottom.
|
||
assert_eq!(app.dialog_plot_origin_mm(), (15.0, 37.0));
|
||
let _ = app.run_command_line("PLOTOFFSET 1");
|
||
assert_eq!(app.dialog_plot_origin_mm(), (10.0, 20.0));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into()));
|
||
let (x, y) = app.dialog_plot_origin_mm();
|
||
assert!((x - 10.0).abs() < 1e-4, "unit change keeps the distance");
|
||
assert!((y - 20.0).abs() < 1e-4);
|
||
}
|
||
|
||
#[test]
|
||
fn sending_a_plot_saves_the_dialog_into_the_layout_when_asked() {
|
||
use crate::ui::window::plot::PlotFlag;
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
assert!(
|
||
app.plot_dialog.save_to_layout,
|
||
"on by default, as expected of a page setup"
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
// A preview never writes the layout.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Preview);
|
||
assert_eq!(layout_paper(&app).0, "A4");
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Commit);
|
||
assert_eq!(layout_paper(&app).0, "ISO_A3_(297.00_x_420.00_MM)");
|
||
// Switched off, a plot leaves the layout alone.
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::SaveToLayout));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A2_(420.00_x_594.00_MM)".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Commit);
|
||
assert_eq!(layout_paper(&app).0, "ISO_A3_(297.00_x_420.00_MM)");
|
||
}
|
||
|
||
#[test]
|
||
fn paperupdate_decides_what_happens_to_an_unsupported_sheet() {
|
||
use crate::io::paper_catalog::{resolve, Margins};
|
||
use crate::io::plot_device::{PrinterCapabilities, PrinterMedia};
|
||
let a3 = resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap();
|
||
let media = PrinterMedia {
|
||
paper: a3.clone(),
|
||
margins: Margins::uniform(3.0),
|
||
borderless: false,
|
||
};
|
||
let caps = std::sync::Arc::new(PrinterCapabilities {
|
||
media: vec![media],
|
||
default_paper: Some(a3),
|
||
});
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||
// PAPERUPDATE 0: the sheet is kept and the mismatch reported.
|
||
let media = |caps| PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps));
|
||
let _ = app.on_plot_dlg(media(caps.clone()));
|
||
assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)");
|
||
let last = app
|
||
.command_line
|
||
.history
|
||
.last()
|
||
.map(|line| line.text.clone());
|
||
assert!(
|
||
last.as_deref().unwrap_or("").contains("PAPERUPDATE = 0"),
|
||
"{last:?}"
|
||
);
|
||
// PAPERUPDATE 1: the printer's default sheet takes over, in the
|
||
// dialog's orientation.
|
||
let _ = app.run_command_line("PAPERUPDATE 1");
|
||
let _ = app.on_plot_dlg(media(caps));
|
||
assert_eq!(app.plot_dialog.paper, "ISO_A3_(297.00_x_420.00_MM)");
|
||
assert_eq!(
|
||
super::plot_dialog_sheet_mm(&app.plot_dialog),
|
||
(420.0, 297.0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scale_names_parse_to_their_ratios() {
|
||
use crate::scene::Scene;
|
||
assert_eq!(Scene::parse_scale_name_ratio("1:250"), Some((1.0, 250.0)));
|
||
assert_eq!(Scene::parse_scale_name_ratio("2:1"), Some((2.0, 1.0)));
|
||
assert_eq!(
|
||
Scene::parse_scale_name_ratio("1/8\" = 1'-0\""),
|
||
Some((0.125, 12.0))
|
||
);
|
||
assert_eq!(
|
||
Scene::parse_scale_name_ratio("3/32\" = 1'-0\""),
|
||
Some((0.09375, 12.0))
|
||
);
|
||
assert_eq!(
|
||
Scene::parse_scale_name_ratio("1'-0\" = 1'-0\""),
|
||
Some((12.0, 12.0))
|
||
);
|
||
assert_eq!(
|
||
Scene::parse_scale_name_ratio("6\" = 1'-0\""),
|
||
Some((6.0, 12.0))
|
||
);
|
||
assert_eq!(Scene::parse_scale_name_ratio("Fit"), None);
|
||
assert_eq!(Scene::parse_scale_name_ratio("1:0"), None);
|
||
assert_eq!(super::ratio_scale_name(0.004), "1:250");
|
||
assert_eq!(super::ratio_scale_name(25.4), "25.4:1");
|
||
assert_eq!(super::ratio_scale_name(1.0 / 3.0), "1:3");
|
||
}
|
||
|
||
#[test]
|
||
fn dialog_shows_the_catalogue_sheet_for_a_driver_name() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)");
|
||
assert_eq!(app.plot_dialog.orientation, "Landscape");
|
||
assert_eq!(
|
||
super::plot_dialog_sheet_mm(&app.plot_dialog),
|
||
(297.0, 210.0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn applying_an_unchanged_sheet_keeps_the_drawing_media_name() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let (name, w, h) = layout_paper(&app);
|
||
assert_eq!(
|
||
name, "A4",
|
||
"the driver's own spelling must survive the dialog"
|
||
);
|
||
// …and so must the driver's landscape medium: unrotated, as stored.
|
||
assert_eq!((w, h), (297.0, 210.0));
|
||
assert_eq!(layout_rotation(&app), codec::objects::PlotRotation::None);
|
||
}
|
||
|
||
#[test]
|
||
fn picking_another_sheet_writes_the_canonical_name() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
assert_eq!(
|
||
super::plot_dialog_sheet_mm(&app.plot_dialog),
|
||
(420.0, 297.0)
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let (name, w, h) = layout_paper(&app);
|
||
assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)");
|
||
assert_eq!((w, h), (297.0, 420.0));
|
||
assert_eq!(
|
||
layout_rotation(&app),
|
||
codec::objects::PlotRotation::Degrees90
|
||
);
|
||
// The next dialog remembers the sheet the user chose.
|
||
assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)");
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_output_is_stored_as_the_source_app_pdf_driver() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[app.active_tab]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.unwrap();
|
||
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
|
||
// A changed device takes the PDF driver's printable area for the sheet.
|
||
assert_eq!(
|
||
ps.margins,
|
||
codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn svg_device_is_session_state_and_stores_as_pdf() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
// The SVG device behaves like file output…
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_SVG.into()));
|
||
assert!(app.plot_dialog.to_file);
|
||
assert!(app.plot_dialog.file_svg);
|
||
// …but switching back to PDF (or a printer) clears it.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
assert!(app.plot_dialog.to_file);
|
||
assert!(!app.plot_dialog.file_svg);
|
||
// The drawing never learns about SVG: page setups only know printers
|
||
// and PDF drivers, so an SVG plot stores the PDF driver spelling.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_SVG.into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
|
||
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_pdf_label_is_upgraded_on_save() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let i = app.active_tab;
|
||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
ps.printer_name = "Save to PDF file…".into();
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
let _ = app.on_plot_dialog_open();
|
||
assert!(
|
||
app.plot_dialog.to_file,
|
||
"the legacy label still means PDF output"
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
|
||
}
|
||
|
||
#[test]
|
||
fn a_plotter_configuration_from_source_app_is_preserved_with_its_margins() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let i = app.active_tab;
|
||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
ps.printer_name = "DWF6 ePlot.pc3".into();
|
||
ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||
ps.margins = codec::objects::PaperMargin::new(5.793749, 17.793753, 5.793744, 17.793747);
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
let _ = app.on_plot_dialog_open();
|
||
assert!(
|
||
app.plot_dialog.to_file,
|
||
"an unknown plotter plots to PDF here"
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let after = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
assert_eq!(after.printer_name, "DWF6 ePlot.pc3");
|
||
assert_eq!(
|
||
after.margins, ps.margins,
|
||
"the source application's driver margins survive an unchanged setup"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn changing_the_sheet_updates_the_printable_area() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let i = app.active_tab;
|
||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
ps.printer_name = "DWG To PDF.pc3".into();
|
||
ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||
ps.margins = codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0);
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper(
|
||
"ISO_full_bleed_A4_(210.00_x_297.00_MM)".into(),
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
assert_eq!(ps.paper_size, "ISO_full_bleed_A4_(210.00_x_297.00_MM)");
|
||
assert_eq!(
|
||
ps.margins,
|
||
codec::objects::PaperMargin::new(0.0, 1.0, 0.0, 1.0)
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
assert_eq!(
|
||
ps.margins,
|
||
codec::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)
|
||
);
|
||
assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0));
|
||
}
|
||
|
||
#[test]
|
||
fn a_selected_printer_is_stored_by_name() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||
assert!(!app.plot_dialog.to_file);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[app.active_tab]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.unwrap();
|
||
assert_eq!(ps.printer_name, "OCS Test Printer");
|
||
// Reopening resolves the stored name back to the same printer.
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.printer.as_deref(), Some("OCS Test Printer"));
|
||
// A media answer for a printer that is no longer selected is ignored.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
let caps = std::sync::Arc::new(crate::io::plot_device::PrinterCapabilities::default());
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterMedia(
|
||
"OCS Test Printer".into(),
|
||
Some(caps),
|
||
));
|
||
assert!(app.plot_dialog.printer_media.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_custom_sheet_is_added_selected_persisted_and_written_with_its_margins() {
|
||
use crate::ui::window::plot::{CustomPaperMsg as C, MarginSide};
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||
let custom = |m: C| PlotDlgMsg::CustomPaper(m);
|
||
let _ = app.on_plot_dlg(custom(C::Open));
|
||
// Seeded from the selected ISO A4 on the PDF driver, nameless.
|
||
let draft = app.plot_dialog.custom_editor.clone().expect("editor open");
|
||
assert_eq!(
|
||
(draft.width.as_str(), draft.height.as_str()),
|
||
("210", "297")
|
||
);
|
||
assert_eq!(draft.margins, ["5", "17", "6", "18"]);
|
||
assert!(draft.name.is_empty());
|
||
let _ = app.on_plot_dlg(custom(C::Name("Roll 24".into())));
|
||
let _ = app.on_plot_dlg(custom(C::Width("1500".into())));
|
||
let _ = app.on_plot_dlg(custom(C::Height("609.6".into())));
|
||
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "3".into())));
|
||
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Top, "4".into())));
|
||
let _ = app.on_plot_dlg(custom(C::Add));
|
||
assert!(
|
||
app.plot_dialog.custom_editor.is_none(),
|
||
"Add closes the editor"
|
||
);
|
||
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
|
||
assert_eq!(
|
||
super::plot_dialog_sheet_mm(&app.plot_dialog),
|
||
(1500.0, 609.6)
|
||
);
|
||
assert_eq!(
|
||
app.current_config().plot.custom_papers.len(),
|
||
1,
|
||
"persisted in the plot config"
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let ps = app.tabs[app.active_tab]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.unwrap();
|
||
assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)");
|
||
assert_eq!((ps.paper_width, ps.paper_height), (609.6, 1500.0));
|
||
assert_eq!(ps.rotation, codec::objects::PlotRotation::Degrees90);
|
||
assert_eq!(
|
||
ps.margins,
|
||
codec::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0)
|
||
);
|
||
// Re-adding the same size replaces the definition instead of duplicating it.
|
||
let _ = app.on_plot_dlg(custom(C::Open));
|
||
assert_eq!(
|
||
app.plot_dialog
|
||
.custom_editor
|
||
.as_ref()
|
||
.map(|d| d.name.as_str()),
|
||
Some("Roll 24")
|
||
);
|
||
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "9".into())));
|
||
let _ = app.on_plot_dlg(custom(C::Add));
|
||
assert_eq!(app.plot_dialog.custom_papers.len(), 1);
|
||
assert_eq!(app.plot_dialog.custom_papers[0].margins.left, 9.0);
|
||
// Remove drops the definition; the sheet itself stays selectable.
|
||
let _ = app.on_plot_dlg(custom(C::Remove));
|
||
assert!(app.plot_dialog.custom_papers.is_empty());
|
||
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
|
||
}
|
||
|
||
#[test]
|
||
fn an_invalid_custom_sheet_is_refused_with_a_reason() {
|
||
use crate::ui::window::plot::{CustomPaperError, CustomPaperMsg as C};
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Open));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("-5".into())));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Add));
|
||
let draft = app
|
||
.plot_dialog
|
||
.custom_editor
|
||
.as_ref()
|
||
.expect("editor stays open");
|
||
assert_eq!(draft.error, Some(CustomPaperError::Size));
|
||
assert!(app.plot_dialog.custom_papers.is_empty());
|
||
// Typing again clears the message.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("500".into())));
|
||
assert!(app
|
||
.plot_dialog
|
||
.custom_editor
|
||
.as_ref()
|
||
.unwrap()
|
||
.error
|
||
.is_none());
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Cancel));
|
||
assert!(app.plot_dialog.custom_editor.is_none());
|
||
}
|
||
|
||
// The driver-options editor is the CUPS flow; on Windows
|
||
// PrinterProperties routes to the system preferences dialog instead, so
|
||
// the editor under test never opens there.
|
||
#[cfg(not(target_os = "windows"))]
|
||
#[test]
|
||
fn driver_options_are_edited_remembered_and_sent_with_the_job() {
|
||
use crate::io::print_to_printer::parse_lpoptions;
|
||
const LPOPTIONS: &str = "MediaType/Media Type: *Stationery Photographic\n\
|
||
ColorModel/Print Color Mode: *RGB Gray\n\
|
||
cupsPrintQuality/Print Quality: *Normal High\n";
|
||
let mut app = app_with_printer_named_sheet();
|
||
let _ = app.on_plot_dialog_open();
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
|
||
let draft = app
|
||
.plot_dialog
|
||
.printer_editor
|
||
.as_ref()
|
||
.expect("editor opens");
|
||
assert_eq!(draft.printer, "OCS Test Printer");
|
||
assert!(draft.options.is_none(), "options load in the background");
|
||
// An answer for another printer is ignored; the right one fills the editor.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||
"Other".into(),
|
||
Ok(parse_lpoptions(LPOPTIONS)),
|
||
));
|
||
assert!(app
|
||
.plot_dialog
|
||
.printer_editor
|
||
.as_ref()
|
||
.unwrap()
|
||
.options
|
||
.is_none());
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||
"OCS Test Printer".into(),
|
||
Ok(parse_lpoptions(LPOPTIONS)),
|
||
));
|
||
let draft = app.plot_dialog.printer_editor.as_ref().unwrap();
|
||
assert_eq!(draft.options.as_ref().map(|o| o.len()), Some(3));
|
||
assert_eq!(draft.choices["ColorModel"], "RGB");
|
||
assert!(draft.overrides().is_empty(), "defaults are not overrides");
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet(
|
||
"ColorModel".into(),
|
||
"Gray".into(),
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet(
|
||
"cupsPrintQuality".into(),
|
||
"High".into(),
|
||
));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
|
||
assert!(
|
||
app.plot_dialog.printer_editor.is_none(),
|
||
"Apply closes the editor"
|
||
);
|
||
let remembered = &app.plot_dialog.driver_options["OCS Test Printer"];
|
||
assert_eq!(remembered.len(), 2);
|
||
assert_eq!(remembered["ColorModel"], "Gray");
|
||
// The job carries exactly those overrides…
|
||
let opts = app.plot_print_options(&app.plot_dialog);
|
||
assert_eq!(
|
||
opts.driver_options,
|
||
vec![
|
||
("ColorModel".to_string(), "Gray".to_string()),
|
||
("cupsPrintQuality".to_string(), "High".to_string())
|
||
]
|
||
);
|
||
// …they survive in the config, and another printer gets none of them.
|
||
assert_eq!(app.current_config().plot.driver_options.len(), 1);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("Another".into()));
|
||
assert!(app
|
||
.plot_print_options(&app.plot_dialog)
|
||
.driver_options
|
||
.is_empty());
|
||
// Reopening the editor starts from the remembered choices; Reset then
|
||
// Apply forgets them.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||
"OCS Test Printer".into(),
|
||
Ok(parse_lpoptions(LPOPTIONS)),
|
||
));
|
||
assert_eq!(
|
||
app.plot_dialog.printer_editor.as_ref().unwrap().choices["ColorModel"],
|
||
"Gray"
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsReset);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
|
||
assert!(app
|
||
.plot_dialog
|
||
.driver_options
|
||
.get("OCS Test Printer")
|
||
.is_none());
|
||
// A failed listing is reported inside the editor, not lost.
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||
"OCS Test Printer".into(),
|
||
Err("no cups".into()),
|
||
));
|
||
assert_eq!(
|
||
app.plot_dialog
|
||
.printer_editor
|
||
.as_ref()
|
||
.unwrap()
|
||
.error
|
||
.as_deref(),
|
||
Some("no cups")
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsCancel);
|
||
assert!(app.plot_dialog.printer_editor.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() {
|
||
let mut app = app_with_printer_named_sheet();
|
||
let i = app.active_tab;
|
||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||
ps.paper_size = "Roll_24_(609.60_x_1500.00_MM)".into();
|
||
ps.paper_width = 1500.0;
|
||
ps.paper_height = 609.6;
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
|
||
assert_eq!(
|
||
super::plot_dialog_sheet_mm(&app.plot_dialog),
|
||
(1500.0, 609.6)
|
||
);
|
||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||
let (name, w, h) = layout_paper(&app);
|
||
assert_eq!(name, "Roll_24_(609.60_x_1500.00_MM)");
|
||
// The drawing's own landscape medium stays as it was stored.
|
||
assert_eq!((w, h), (1500.0, 609.6));
|
||
assert_eq!(layout_rotation(&app), codec::objects::PlotRotation::None);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod plot_device_persistence_tests {
|
||
use super::*;
|
||
|
||
/// Reopening the Plot dialog must keep the user's printer. Layouts in
|
||
/// real drawings carry plot settings with no device, and the auto-applied
|
||
/// page setup used to reset the choice to the system default on every
|
||
/// open.
|
||
#[test]
|
||
fn reopening_the_plot_dialog_keeps_the_chosen_printer() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
// A real drawing's layout carries plot settings with no device; the
|
||
// auto-applied setup used to reset the choice to the default.
|
||
let mut ps = codec::objects::PlotSettings::default();
|
||
ps.printer_name = String::new();
|
||
let layout = app.tabs[app.active_tab].scene.current_layout.clone();
|
||
assert!(
|
||
app.tabs[app.active_tab]
|
||
.scene
|
||
.set_layout_plot_settings(&layout, &ps),
|
||
"the test layout must accept plot settings"
|
||
);
|
||
app.plot_dialog.printer = Some("Brother DCP-L2520D series".into());
|
||
app.plot_dialog.to_file = false;
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(
|
||
app.plot_dialog.printer.as_deref(),
|
||
Some("Brother DCP-L2520D series"),
|
||
"the chosen printer must survive the auto-applied page setup"
|
||
);
|
||
assert!(!app.plot_dialog.to_file);
|
||
}
|
||
|
||
/// Reopening Plot in the same tab/layout keeps the user's most recent
|
||
/// paper, scale and CTB choices instead of reloading the layout page setup
|
||
/// over them. Regression test for #1654.
|
||
#[test]
|
||
fn reopening_same_plot_context_keeps_recent_settings() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
|
||
// First open seeds from the drawing/layout as usual.
|
||
let _ = app.on_plot_dialog_open();
|
||
|
||
// User edits the high-frequency Plot settings.
|
||
app.plot_dialog.paper = "ISO_A3_(297.00_x_420.00_MM)".into();
|
||
app.plot_dialog.paper_width_mm = 297.0;
|
||
app.plot_dialog.paper_height_mm = 420.0;
|
||
app.plot_dialog.scale = "1:100".into();
|
||
app.plot_dialog.fit_to_paper = false;
|
||
app.plot_dialog.style_name = "office.ctb".into();
|
||
app.plot_dialog.apply_plot_styles = true;
|
||
|
||
// Reopening Ctrl+P in the same layout must not reapply the layout's
|
||
// old page setup and erase those choices.
|
||
let _ = app.on_plot_dialog_open();
|
||
|
||
assert_eq!(app.plot_dialog.paper, "ISO_A3_(297.00_x_420.00_MM)");
|
||
assert_eq!(app.plot_dialog.scale, "1:100");
|
||
assert!(!app.plot_dialog.fit_to_paper);
|
||
assert_eq!(app.plot_dialog.style_name, "office.ctb");
|
||
assert!(app.plot_dialog.apply_plot_styles);
|
||
}
|
||
|
||
/// Opening a different drawing in a reused tab must invalidate the Plot
|
||
/// session cache. The first Plot dialog must read paper and CTB from the
|
||
/// newly opened document instead of showing the startup drawing's values.
|
||
#[test]
|
||
fn newly_opened_document_forces_first_plot_dialog_to_read_layout() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let i = app.active_tab;
|
||
|
||
app.tabs[i].scene.current_layout = "Layout1".into();
|
||
|
||
let mut ps = app.tabs[i]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.expect("Layout1 must exist");
|
||
|
||
ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||
ps.paper_width = 420.0;
|
||
ps.paper_height = 594.0;
|
||
ps.current_style_sheet = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||
ps.flags.plot_plot_styles = true;
|
||
|
||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||
|
||
// Reproduce stale state inherited from the startup/previous drawing.
|
||
app.plot_dialog.paper = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||
app.plot_dialog.style_name.clear();
|
||
app.plot_dialog.apply_plot_styles = false;
|
||
|
||
app.plot_prev = Some(app.plot_dialog.clone());
|
||
app.plot_prev_context = Some((i, "Layout1".into()));
|
||
|
||
// Installing another drawing must make that state unusable.
|
||
app.reset_plot_session_for_opened_document();
|
||
|
||
assert!(app.plot_prev.is_none());
|
||
assert!(app.plot_prev_context.is_none());
|
||
|
||
let _ = app.on_plot_dialog_open();
|
||
|
||
assert_eq!(
|
||
app.plot_dialog.paper, "ISO_A2_(420.00_x_594.00_MM)",
|
||
"the first Plot opening must use the new drawing's paper"
|
||
);
|
||
|
||
assert!(
|
||
app.plot_dialog
|
||
.style_name
|
||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE),
|
||
"the first Plot opening must use the new drawing's CTB"
|
||
);
|
||
|
||
assert!(
|
||
app.plot_dialog.apply_plot_styles,
|
||
"the stored CTB must already be enabled on the first Plot opening"
|
||
);
|
||
}
|
||
|
||
/// Print All Options is a shared batch-settings editor. On the first
|
||
/// opening after restarting OCS it must keep the persisted user choices
|
||
/// instead of replacing them with the active layout's page setup.
|
||
#[test]
|
||
fn print_all_options_keeps_persisted_plot_preferences_on_first_open() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
|
||
// Simulate preferences restored from the previous application session.
|
||
app.plot_dialog.paper = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||
app.plot_dialog.paper_width_mm = 420.0;
|
||
app.plot_dialog.paper_height_mm = 594.0;
|
||
app.plot_dialog.scale = "1:1000".into();
|
||
app.plot_dialog.style_name = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||
app.plot_dialog.apply_plot_styles = true;
|
||
|
||
// Fresh process: no explicit Print All override exists yet.
|
||
app.print_all_settings_override = false;
|
||
|
||
let _ = app.on_print_all_options();
|
||
|
||
assert_eq!(
|
||
app.plot_dialog.paper, "ISO_A2_(420.00_x_594.00_MM)",
|
||
"first Print All Options opening must keep the persisted paper"
|
||
);
|
||
|
||
assert_eq!(
|
||
app.plot_dialog.scale, "1:1000",
|
||
"first Print All Options opening must keep the persisted scale"
|
||
);
|
||
|
||
assert!(
|
||
app.plot_dialog
|
||
.style_name
|
||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE),
|
||
"first Print All Options opening must keep the persisted CTB"
|
||
);
|
||
|
||
assert!(
|
||
app.plot_dialog.apply_plot_styles,
|
||
"the persisted CTB must remain enabled"
|
||
);
|
||
|
||
assert!(
|
||
app.print_all_options,
|
||
"the dialog must remain in Print All Options mode"
|
||
);
|
||
}
|
||
|
||
/// `<previous>` belongs to the layout that produced the snapshot.
|
||
/// Returning to that layout with "Save changes to layout" enabled must
|
||
/// preserve the restored paper/CTB instead of reloading the old embedded
|
||
/// page setup over it.
|
||
#[test]
|
||
fn previous_snapshot_is_saved_back_to_its_origin_layout() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let i = app.active_tab;
|
||
|
||
app.tabs[i].scene.current_layout = "Layout1".into();
|
||
|
||
// Layout1 still contains an old page setup.
|
||
let mut stored = app.tabs[i]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.expect("Layout1 must exist");
|
||
|
||
stored.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||
stored.paper_width = 210.0;
|
||
stored.paper_height = 297.0;
|
||
stored.current_style_sheet.clear();
|
||
stored.flags.plot_plot_styles = false;
|
||
|
||
assert!(app.tabs[i]
|
||
.scene
|
||
.set_layout_plot_settings("Layout1", &stored));
|
||
|
||
// `<previous>` is the desired state that originally belonged to
|
||
// Layout1.
|
||
app.print_all_options = true;
|
||
app.plot_prev_context = Some((i, "Layout1".into()));
|
||
app.plot_dialog.selected_setup = crate::ui::window::plot::SETUP_PREV.into();
|
||
|
||
app.plot_dialog.paper = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||
app.plot_dialog.paper_width_mm = 420.0;
|
||
app.plot_dialog.paper_height_mm = 594.0;
|
||
app.plot_dialog.style_name = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||
app.plot_dialog.apply_plot_styles = true;
|
||
app.plot_dialog.save_to_layout = true;
|
||
|
||
let _ = app.on_plot_dlg(crate::ui::window::plot::PlotDlgMsg::SelectSetup(
|
||
"*Layout1*".into(),
|
||
));
|
||
|
||
let saved = app.tabs[i]
|
||
.scene
|
||
.plot_settings_for("Layout1")
|
||
.expect("Layout1 must still exist");
|
||
|
||
assert_eq!(saved.paper_size, "ISO_A2_(420.00_x_594.00_MM)");
|
||
|
||
assert!(saved
|
||
.current_style_sheet
|
||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE));
|
||
|
||
assert!(saved.flags.plot_plot_styles);
|
||
|
||
assert_eq!(app.plot_dialog.paper, "ISO_A2_(420.00_x_594.00_MM)");
|
||
|
||
assert!(app
|
||
.plot_dialog
|
||
.style_name
|
||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE));
|
||
}
|
||
|
||
/// A CTB selected in the dialog must be usable even when the parsed
|
||
/// active table was not restored with it. This is the state seen after
|
||
/// reopening a drawing: the dropdown can name the correct CTB while the
|
||
/// first preview otherwise plots using raw layer colours.
|
||
#[test]
|
||
fn selected_ctb_is_resolved_when_active_table_is_stale() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
|
||
app.plot_dialog.style_name = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||
app.plot_dialog.apply_plot_styles = true;
|
||
app.plot_dialog.style_missing = false;
|
||
|
||
// Reproduce a fresh-session mismatch: the visible selection exists,
|
||
// but no matching parsed table is active yet.
|
||
app.active_plot_style = None;
|
||
|
||
let table = app
|
||
.dialog_plot_style(&app.plot_dialog)
|
||
.expect("selected CTB must be resolved by name");
|
||
|
||
assert!(
|
||
table
|
||
.name
|
||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE),
|
||
"the table used for plotting must match the visible CTB selection"
|
||
);
|
||
}
|
||
|
||
/// Printer capability discovery on merely opening Plot must never mutate
|
||
/// the paper stored in the drawing. PAPERUPDATE is applied only after an
|
||
/// explicit device/page-setup choice.
|
||
#[test]
|
||
fn opening_plot_does_not_replace_sheet_when_printer_media_arrives() {
|
||
use crate::io::paper_catalog::{resolve, Margins};
|
||
use crate::io::plot_device::{PrinterCapabilities, PrinterMedia};
|
||
|
||
let a2 = resolve("ISO_A2_(420.00_x_594.00_MM)").expect("A2");
|
||
let a4 = resolve("ISO_A4_(210.00_x_297.00_MM)").expect("A4");
|
||
|
||
let caps = std::sync::Arc::new(PrinterCapabilities {
|
||
media: vec![PrinterMedia {
|
||
paper: a4.clone(),
|
||
margins: Margins::uniform(3.0),
|
||
borderless: false,
|
||
}],
|
||
default_paper: Some(a4),
|
||
});
|
||
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.plot_dialog.to_file = false;
|
||
app.plot_dialog.printer = Some("OCS Test Printer".into());
|
||
app.plot_dialog.paper = a2.canonical.to_string();
|
||
app.plot_dialog.paper_update = 1;
|
||
|
||
// No explicit printer change is pending: this models the asynchronous
|
||
// capability reply caused only by opening the Plot dialog.
|
||
app.plot_reconcile_printer_media = None;
|
||
|
||
let _ = app.on_plot_dlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(
|
||
"OCS Test Printer".into(),
|
||
Some(caps),
|
||
));
|
||
|
||
assert_eq!(
|
||
app.plot_dialog.paper, a2.canonical,
|
||
"opening Plot must preserve the drawing's stored sheet"
|
||
);
|
||
}
|
||
|
||
/// A layout whose plot settings name an explicit device wins over the
|
||
/// saved preference: selecting its setup is an explicit device choice.
|
||
#[test]
|
||
fn layout_device_overrides_the_saved_preference() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.plot_dialog.printer = Some("Brother DCP-L2520D series".into());
|
||
let mut ps = codec::objects::PlotSettings::default();
|
||
ps.printer_name = "Godex G500".into();
|
||
app.load_plotsettings_into_dialog(&ps);
|
||
assert_eq!(app.plot_dialog.printer.as_deref(), Some("Godex G500"));
|
||
assert!(!app.plot_dialog.to_file);
|
||
}
|
||
|
||
/// A persisted plot window (saved with the dialog settings) seeds the
|
||
/// runtime window when the dialog reopens, so `area == "Window"` keeps
|
||
/// plotting the same rectangle instead of reporting an empty area.
|
||
#[test]
|
||
fn persisted_window_seeds_the_dialog_on_reopen() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
assert!(app.plot_window.is_none());
|
||
app.plot_dialog.area = "Window".into();
|
||
app.plot_dialog.window = Some((-10.0, -5.0, 30.0, 15.0));
|
||
let _ = app.on_plot_dialog_open();
|
||
assert_eq!(app.plot_window, Some((-10.0, -5.0, 30.0, 15.0)));
|
||
}
|
||
|
||
/// A window freshly loaded from a layout's plot settings is mirrored into
|
||
/// the dialog so it persists with the next config save.
|
||
#[test]
|
||
fn layout_window_is_mirrored_into_the_dialog() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let mut ps = codec::objects::PlotSettings::default();
|
||
ps.plot_type = codec::objects::PlotType::Window;
|
||
ps.set_plot_window(1.0, 2.0, 3.0, 4.0);
|
||
app.load_plotsettings_into_dialog(&ps);
|
||
assert_eq!(app.plot_window, Some((1.0, 2.0, 3.0, 4.0)));
|
||
assert_eq!(app.plot_dialog.window, Some((1.0, 2.0, 3.0, 4.0)));
|
||
}
|
||
|
||
/// A layout pointing at a PDF device switches the dialog to file output.
|
||
#[test]
|
||
fn pdf_device_switches_to_file_output() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
app.plot_dialog.printer = Some("Brother DCP-L2520D series".into());
|
||
let mut ps = codec::objects::PlotSettings::default();
|
||
ps.printer_name = "Microsoft Print to PDF".into();
|
||
app.load_plotsettings_into_dialog(&ps);
|
||
assert!(app.plot_dialog.to_file);
|
||
assert!(app.plot_dialog.printer.is_none());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod background_task_tests {
|
||
use super::background_task;
|
||
use crate::app::{Message, OpenCADStudio};
|
||
use iced::futures::StreamExt;
|
||
use iced::Task;
|
||
|
||
/// Pull the single message a finished background task produces, the same
|
||
/// way the headless automation driver consumes task streams.
|
||
fn next_message(task: Task<Message>) -> Message {
|
||
let mut stream =
|
||
iced_runtime::task::into_stream(task).expect("background_task yields a stream");
|
||
match iced::futures::executor::block_on(stream.next()) {
|
||
Some(iced_runtime::Action::Output(message)) => message,
|
||
other => panic!("expected task output, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn worker_panic_becomes_a_failure_message() {
|
||
let task = background_task(
|
||
"STL export",
|
||
|| panic!("worker exploded"),
|
||
|()| Message::Noop,
|
||
);
|
||
match next_message(task) {
|
||
Message::BackgroundTaskFailed { context, detail } => {
|
||
assert_eq!(context, "STL export");
|
||
assert!(
|
||
detail.contains("worker exploded"),
|
||
"detail should carry the panic message, got: {detail}"
|
||
);
|
||
}
|
||
other => panic!("expected BackgroundTaskFailed, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn worker_panic_is_reported_on_the_command_line() {
|
||
let mut app = OpenCADStudio::new_for_test();
|
||
let task = background_task(
|
||
"STL export",
|
||
|| panic!("worker exploded"),
|
||
|()| Message::Noop,
|
||
);
|
||
app.drive_headless_task(task)
|
||
.expect("failed worker task drives headlessly");
|
||
let error = app
|
||
.command_line
|
||
.last_error
|
||
.as_deref()
|
||
.expect("worker panic reported as an error");
|
||
assert!(error.contains("STL export"), "error = {error}");
|
||
assert!(error.contains("worker exploded"), "error = {error}");
|
||
}
|
||
}
|
||
|
||
impl OpenCADStudio {
|
||
/// Web: one warning lists every unresolved xref and the way to fix it —
|
||
/// dropping the files on the page registers their bytes, and reopening
|
||
/// the drawing (or XRELOAD) then resolves them.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(in crate::app) fn warn_unresolved_xrefs(&mut self, infos: &[crate::io::xref::XrefInfo]) {
|
||
let unresolved = crate::io::xref::unresolved_xref_names(infos);
|
||
if unresolved.is_empty() {
|
||
return;
|
||
}
|
||
let names = unresolved.join(", ");
|
||
self.command_line.push_warning(
|
||
crate::tf!(
|
||
"XREF Unresolved external references: {names} — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD."
|
||
)
|
||
.as_ref(),
|
||
);
|
||
}
|
||
}
|