feat(save): warn before dropping AEC/Civil objects or overwriting on Save-As

Two in-canvas confirmations on the Save-As path:

- AEC/Civil drop warning: when the drawing carries unsupported AEC/Civil
  (application) objects that survive only as verbatim source-version bytes,
  saving to a different version or to DXF would silently drop them. The
  dialog reports the count and offers "Save in source version" (keep) vs
  "Save anyway" (drop). Same-version DWG saves are lossless and skip it.
- Overwrite confirmation: when the chosen filename already exists in the
  target folder, "Replace" / "Cancel" before clobbering it.

The two chain (AEC first, then overwrite) via per-flow acknowledgement flags
reset whenever the Save dialog opens or closes. Detection is a new
io::dropped_on_save_count over the document's Unknown (raw-passthrough)
objects/entities.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-29 11:47:37 +03:00
1 parent ab9285f114
commit b77187e672
7 files changed
+267 -1

No files matched your search

+24
View File
@@ -332,6 +332,13 @@ pub(super) struct OpenCADStudio {
/// The open in-canvas modal dialog, if any (Plan B: shared overlay instead
/// of OS windows).
active_modal: Option<ModalKind>,
/// Set once the user acknowledges the AEC-drop warning, so re-entering the
/// save path proceeds instead of re-showing the warning.
aec_drop_acknowledged: bool,
/// Number of unsupported objects shown in the AEC-drop warning modal.
aec_drop_count: usize,
/// Set once the user confirms overwriting an existing file on Save-As.
overwrite_acknowledged: bool,
/// Pixel offset of the active modal from screen-centre (drag-to-move).
/// Reset to zero whenever a modal closes so each dialog opens centred.
modal_offset: iced::Vector,
@@ -923,6 +930,8 @@ pub enum ModalKind {
DimStyle,
Unsaved,
SaveDialog,
AecDropWarning,
OverwriteWarning,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
AssocPrompt,
PointStyle,
@@ -1093,6 +1102,18 @@ pub enum Message {
UnsavedDialogDiscard,
/// User clicked "Cancel" in the unsaved-changes dialog.
UnsavedDialogCancel,
// ── AEC / unsupported-object drop warning (lossy Save-As) ──────────────
/// Save as the source DWG version so the unsupported objects survive.
AecDropSameVersion,
/// Proceed with the chosen target format, dropping the unsupported objects.
AecDropProceed,
/// Go back to the Save dialog from the AEC-drop warning.
AecDropBack,
// ── Overwrite confirmation (Save-As over an existing file) ────────────
/// Replace the existing file and save.
OverwriteConfirm,
/// Go back to the Save dialog from the overwrite warning.
OverwriteCancel,
/// Save-as path picked for the unsaved-changes → save → close flow.
UnsavedPickedSavePath(Option<std::path::PathBuf>),
// ─────────────────────────────────────────────────────────────────────
@@ -1868,6 +1889,9 @@ impl OpenCADStudio {
main_window: None,
color_pick_target: None,
active_modal: None,
aec_drop_acknowledged: false,
aec_drop_count: 0,
overwrite_acknowledged: false,
modal_offset: iced::Vector::ZERO,
modal_drag_last: None,
modal_dragging: false,
+4
View File
@@ -49,12 +49,16 @@ impl OpenCADStudio {
self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
}
self.save_dialog_entries = crate::io::read_dir_entries(&self.save_dialog_folder.clone());
self.aec_drop_acknowledged = false;
self.overwrite_acknowledged = false;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
Task::none()
}
pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task<Message> {
self.aec_drop_acknowledged = false;
self.overwrite_acknowledged = false;
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
self.active_modal = None;
}
+57 -1
View File
@@ -552,6 +552,23 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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();
@@ -563,7 +580,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
name.to_string()
};
self.save_dialog_filename = filename.clone();
self.save_dialog_filename = filename.clone();
// Confirm before overwriting an existing file in the folder.
#[cfg(not(target_arch = "wasm32"))]
if !self.overwrite_acknowledged
&& self.save_dialog_folder.join(&filename).exists()
{
self.active_modal = Some(crate::app::ModalKind::OverwriteWarning);
return Task::none();
}
let close = self.close_save_dialog_window();
let i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene);
@@ -609,6 +633,38 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
}
/// AEC-drop warning → "Save anyway": accept the loss and proceed with the
/// format the user already chose.
pub(super) fn on_aec_drop_proceed(&mut self) -> Task<Message> {
self.aec_drop_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// AEC-drop warning → "Save in source version": switch the target to the
/// document's own DWG version (where the unsupported objects round-trip as
/// verbatim bytes), then save.
pub(super) fn on_aec_drop_same_version(&mut self) -> Task<Message> {
let src = self.tabs[self.active_tab].scene.document.version;
self.save_dialog_format = crate::io::format_for_version(src, false);
// Strip any extension (e.g. .dxf) so the confirm path appends .dwg.
let stem = std::path::Path::new(&self.save_dialog_filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.save_dialog_filename.clone());
self.save_dialog_filename = stem;
self.aec_drop_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
/// Overwrite warning → "Replace": save over the existing file.
pub(super) fn on_overwrite_confirm(&mut self) -> Task<Message> {
self.overwrite_acknowledged = true;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
self.on_save_dialog_confirm()
}
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
let i = self.active_tab;
let layout_name = self.tabs[i].scene.current_layout.clone();
+13
View File
@@ -2527,6 +2527,19 @@ impl OpenCADStudio {
Message::UnsavedDialogSave => self.on_unsaved_dialog_save(),
Message::AecDropSameVersion => self.on_aec_drop_same_version(),
Message::AecDropProceed => self.on_aec_drop_proceed(),
Message::AecDropBack => {
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
Task::none()
}
Message::OverwriteConfirm => self.on_overwrite_confirm(),
Message::OverwriteCancel => {
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
Task::none()
}
Message::UnsavedPickedSavePath(Some(path)) => {
self.on_unsaved_picked_save_path_some(path)
}
+2
View File
@@ -1358,6 +1358,8 @@ impl OpenCADStudio {
DimStyle => (720, 560),
AssocPrompt => (440, 210),
Unsaved => (420, 160),
AecDropWarning => (480, 230),
OverwriteWarning => (420, 180),
SaveDialog => (560, 480),
PointStyle => (360, 470),
};
+136
View File
@@ -466,6 +466,25 @@ impl OpenCADStudio {
), 720, 560)
}
super::super::ModalKind::AssocPrompt => sized(default_assoc_dialog_window(), 440, 210),
super::super::ModalKind::AecDropWarning => {
let src_label = self
.tabs
.get(self.active_tab)
.map(|t| crate::io::format_for_version(t.scene.document.version, false))
.unwrap_or_else(|| "DWG".to_string());
sized(
aec_drop_dialog_window(
self.aec_drop_count,
&self.save_dialog_format,
&src_label,
),
480,
230,
)
}
super::super::ModalKind::OverwriteWarning => {
sized(overwrite_dialog_window(&self.save_dialog_filename), 420, 180)
}
super::super::ModalKind::Unsaved => {
let tab_name = match &self.pending_close {
Some(super::super::PendingClose::Tab(idx)) => self
@@ -880,6 +899,123 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
.into()
}
/// Warning shown before a lossy Save-As: the drawing carries unsupported
/// (AEC / application) objects that survive only as verbatim source-version
/// bytes, so saving to a different version or to DXF would drop them. Offers to
/// save in the source version (keep them) or proceed (drop them).
fn aec_drop_dialog_window(count: usize, target: &str, src_version: &str) -> Element<'static, Message> {
const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 };
const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 };
const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 };
const BTN_SAVE: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 };
const BTN_HOVER: Color = Color { r: 0.26, g: 0.55, b: 0.92, a: 1.0 };
const BTN_DISC: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 };
const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 };
let body_text = format!(
"This drawing contains {count} AEC/Civil objects that \"{target}\" \
cannot store, so they will not be saved.\n\n\
To keep them, save in the source version ({src_version})."
);
let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
button(text(label).size(13).color(TEXT_COL))
.on_press(msg)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => hov,
_ => base,
})),
text_color: TEXT_COL,
border: Border {
color: BORDER_COL,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
})
.padding([6, 14])
};
container(
column![
text(body_text).size(13).color(TEXT_COL),
iced::widget::Space::new().height(20),
row![
btn("Save in source version", Message::AecDropSameVersion, BTN_SAVE, BTN_HOVER),
iced::widget::Space::new().width(8),
btn("Save anyway", Message::AecDropProceed, BTN_DISC, BTN_DHOV),
iced::widget::Space::new().width(8),
btn("Back", Message::AecDropBack, BTN_DISC, BTN_DHOV),
],
]
.spacing(0),
)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.center(Fill)
.padding([24, 28])
.into()
}
/// Confirmation shown when the chosen Save-As filename already exists in the
/// target folder. "Replace" overwrites; "Cancel" returns to the Save dialog.
fn overwrite_dialog_window(filename: &str) -> Element<'static, Message> {
const BG: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 };
const BORDER_COL: Color = Color { r: 0.38, g: 0.38, b: 0.42, a: 1.0 };
const TEXT_COL: Color = Color { r: 0.90, g: 0.90, b: 0.90, a: 1.0 };
const BTN_SAVE: Color = Color { r: 0.20, g: 0.46, b: 0.80, a: 1.0 };
const BTN_HOVER: Color = Color { r: 0.26, g: 0.55, b: 0.92, a: 1.0 };
const BTN_DISC: Color = Color { r: 0.28, g: 0.28, b: 0.30, a: 1.0 };
const BTN_DHOV: Color = Color { r: 0.36, g: 0.36, b: 0.40, a: 1.0 };
let body_text =
format!("\"{filename}\" already exists in this folder.\n\nDo you want to replace it?");
let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
button(text(label).size(13).color(TEXT_COL))
.on_press(msg)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => hov,
_ => base,
})),
text_color: TEXT_COL,
border: Border {
color: BORDER_COL,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
})
.padding([6, 18])
};
container(
column![
text(body_text).size(13).color(TEXT_COL),
iced::widget::Space::new().height(20),
row![
btn("Replace", Message::OverwriteConfirm, BTN_SAVE, BTN_HOVER),
iced::widget::Space::new().width(8),
btn("Cancel", Message::OverwriteCancel, BTN_DISC, BTN_DHOV),
],
]
.spacing(0),
)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.center(Fill)
.padding([24, 28])
.into()
}
/// First-launch prompt offering to register Open CAD Studio as the default
/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
/// just dismisses. Either answer flips the persisted `default_assoc_prompted`
+31
View File
@@ -442,6 +442,37 @@ pub fn format_for_version(version: acadrust::DxfVersion, is_dxf: bool) -> String
format!("{} {}", if is_dxf { "DXF" } else { "DWG" }, year)
}
/// Count of unsupported "raw passthrough" objects/entities — AEC / application
/// objects with no native representation, kept only as verbatim source-version
/// bytes — that would be DROPPED when saving `doc` to `target_version` (or to
/// DXF). Returns 0 for a same-version DWG save, where they round-trip verbatim.
/// Used to warn the user before a lossy Save-As.
pub fn dropped_on_save_count(
doc: &acadrust::CadDocument,
target_version: acadrust::DxfVersion,
is_dxf: bool,
) -> usize {
if !is_dxf && target_version == doc.version {
return 0;
}
let mut n = doc
.objects
.values()
.filter(|o| {
matches!(
o,
acadrust::objects::ObjectType::Unknown { raw_dwg_data: Some(_), .. }
)
})
.count();
for e in doc.entities() {
if matches!(e, acadrust::EntityType::Unknown(_)) {
n += 1;
}
}
n
}
// ── Plot Style Table ──────────────────────────────────────────────────────
/// Show a file-open dialog and load the selected CTB or STB file.