Files
OneCAD-Draft/src/app/update/file.rs
T
Hakan SevenandClaude Opus 5.5 85627ebc8a feat(web): return saved drawings to an embedding page
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>
2026-10-13 10:12:07 +03:00

8782 lines
374 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
#![allow(unused_imports)]
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::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(),
);
}
}