fix(save): preserve 3D view and thumbnails

Persist camera, render state, and solid fallback geometry across saves. Generate previews from a clean live viewport and refresh recent-file thumbnails immediately.
This commit is contained in:
Hakan Seven committed 2026-08-23 17:59:12 +03:00
1 parent c4515cbbc1
commit e94cbedaa2
15 files changed
+658 -629

No files matched your search

-6
View File
@@ -195,10 +195,6 @@ pub(super) struct DocumentTab {
pub(super) active_mleader_style: String,
/// Last camera_generation value written back to the document.
pub(super) last_synced_camera_gen: u64,
/// Render-state key of `scene.document.preview`. Matching saves reuse the
/// encoded DWG thumbnail instead of rescanning every resident wire.
#[cfg(not(target_arch = "wasm32"))]
pub(super) thumbnail_cache_key: Option<super::ThumbnailCacheKey>,
/// Sentinel "Welcome / Start" tab. Always at index 0 when present.
/// Cannot be closed; the viewport area renders a welcome page instead
/// of the model-space shader. The scene is still constructed so the
@@ -486,8 +482,6 @@ impl DocumentTab {
active_block_edit: None,
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
#[cfg(not(target_arch = "wasm32"))]
thumbnail_cache_key: None,
is_start: false,
pan_mode: false,
orbit_mode: false,
+47 -11
View File
@@ -608,6 +608,12 @@ pub(super) struct OpenCADStudio {
/// OS window Id for the floating Layer Properties Manager (None when closed).
/// OS window Id of the primary application window.
main_window: Option<window::Id>,
/// Hides drawing UI overlays for one thumbnail capture frame.
thumbnail_capture_clean: bool,
#[cfg(not(target_arch = "wasm32"))]
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: Option<PendingWebThumbnailSave>,
// ── Floating panel windows ────────────────────────────────────────────
/// Active `iced_aw` colour picker: destination plus its initial colour.
color_pick_target: Option<(ColorPickTarget, AcadColor)>,
@@ -1131,6 +1137,28 @@ pub(super) enum SaveContinuation {
Quit,
}
#[derive(Debug, Clone)]
#[cfg(not(target_arch = "wasm32"))]
pub(super) struct PendingNativeThumbnailSave {
tab_id: u64,
path: PathBuf,
version: acadrust::DxfVersion,
purpose: SavePurpose,
continuation: SaveContinuation,
set_current_path: bool,
check_external_change: bool,
}
#[derive(Debug, Clone)]
#[cfg(target_arch = "wasm32")]
pub(super) struct PendingWebThumbnailSave {
tab_id: u64,
filename: String,
ext: String,
version: acadrust::DxfVersion,
bounds: iced::Rectangle,
}
#[derive(Debug, Clone)]
#[cfg(not(target_arch = "wasm32"))]
pub(super) struct PendingSaveFailure {
@@ -1154,16 +1182,6 @@ pub(super) struct PendingExternalChange {
set_current_path: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(not(target_arch = "wasm32"))]
pub(super) struct ThumbnailCacheKey {
epoch: u64,
camera_generation: u64,
bg_color: [u32; 4],
png: bool,
viewport: [u32; 2],
}
#[derive(Debug, Clone)]
#[cfg(not(target_arch = "wasm32"))]
pub struct SaveOutcome {
@@ -1178,7 +1196,6 @@ pub struct SaveOutcome {
set_current_path: bool,
purpose: SavePurpose,
continuation: SaveContinuation,
thumbnail_key: Option<ThumbnailCacheKey>,
refreshed_preview: Option<Option<acadrust::Preview>>,
result: Result<(), crate::io::SaveFailure>,
}
@@ -1915,9 +1932,23 @@ pub enum Message {
AecDropBack,
/// Periodic autosave tick — write `.sv$` recovery files for dirty tabs.
AutoSave,
/// A clean viewport frame is ready for thumbnail capture.
ThumbnailCaptureFrame,
/// Restore drawing UI after the compositor screenshot is captured.
ThumbnailCaptureFinished,
/// Native background save/autosave completed.
#[cfg(not(target_arch = "wasm32"))]
SaveFinished(SaveOutcome),
/// Web viewport capture completed; serialize and download the drawing.
#[cfg(target_arch = "wasm32")]
WebSaveScreenshot {
tab_id: u64,
filename: String,
ext: String,
version: acadrust::DxfVersion,
bounds: Option<iced::Rectangle>,
screenshot: Option<iced::window::Screenshot>,
},
/// Retry the failed save after the other application releases the file.
#[cfg(not(target_arch = "wasm32"))]
SaveFileInUseRetry,
@@ -3161,6 +3192,11 @@ impl OpenCADStudio {
show_layout_tabs: true,
last_point: None,
main_window: None,
thumbnail_capture_clean: false,
#[cfg(not(target_arch = "wasm32"))]
pending_native_thumbnail_save: None,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: None,
color_pick_target: None,
color_picker_tab: ColorPickerTab::Index,
recent_colors: Vec::new(),
+368 -178
View File
@@ -237,36 +237,72 @@ fn plot_scene_content(
}
impl OpenCADStudio {
/// Before a save, give every cached solid that still has no ACIS
/// geometry (EXTRUDE/REVOLVE/SWEEP/LOFT/boolean results) an exact modeler
/// body derived from its B-rep, so the written DWG/DXF carries real
/// 3-D geometry other CAD apps can open instead of an empty data stream.
/// A body holding something the kernel has no ACIS record form for is
/// left untouched rather than written out half-complete.
fn sync_solid_models_to_acis(&mut self, i: usize) {
/// Persist exact ACIS bodies and kernel-derived edge caches before saving.
fn sync_solid_models_for_save(&mut self, i: usize) {
use acadrust::EntityType;
let scene = &mut self.tabs[i].scene;
let targets: Vec<acadrust::Handle> = scene
.solid_models
.keys()
.copied()
.filter(|h| {
matches!(
scene.document.get_entity(*h),
Some(EntityType::Solid3D(s)) if !s.acis_data.has_data()
)
let targets: Vec<(acadrust::Handle, bool, bool)> = scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Solid3D(solid) = entity else {
return None;
};
let h = solid.common.handle;
let needs_acis = !solid.acis_data.has_data();
let needs_wires = solid.wires.is_empty();
(needs_acis || needs_wires).then_some((h, needs_acis, needs_wires))
})
.collect();
for h in targets {
// Build the SAT while borrowing solid_models; the returned document
// is owned, so the borrow ends before we mutate the entity.
let sat = scene
.solid_models
.get(&h)
.and_then(crate::scene::convert::acis_export::planar_solid_to_sat);
if let Some(sat) = sat {
if let Some(EntityType::Solid3D(s)) = scene.document.get_entity_mut(h) {
s.set_sat_document(&sat);
for (h, needs_acis, needs_wires) in targets {
let body = scene.solid_models.get(&h);
let sat = needs_acis
.then(|| {
body.and_then(crate::scene::convert::acis_export::planar_solid_to_sat)
})
.flatten();
let wires = needs_wires.then(|| {
if let Some(body) = body {
return crate::scene::model::solid_model::edge_wires(body);
}
let Some(mesh) = scene.meshes.get(&h).or_else(|| scene.block_meshes.get(&h)) else {
return Vec::new();
};
mesh.edge_verts
.chunks_exact(2)
.enumerate()
.map(|(index, points)| {
let first_low = mesh
.edge_verts_low
.get(index * 2)
.copied()
.unwrap_or([0.0; 3]);
let second_low = mesh
.edge_verts_low
.get(index * 2 + 1)
.copied()
.unwrap_or([0.0; 3]);
acadrust::entities::Wire::from_points(vec![
acadrust::types::Vector3::new(
points[0][0] as f64 + first_low[0] as f64,
points[0][1] as f64 + first_low[1] as f64,
points[0][2] as f64 + first_low[2] as f64,
),
acadrust::types::Vector3::new(
points[1][0] as f64 + second_low[0] as f64,
points[1][1] as f64 + second_low[1] as f64,
points[1][2] as f64 + second_low[2] as f64,
),
])
})
.collect()
});
if let Some(EntityType::Solid3D(solid)) = scene.document.get_entity_mut(h) {
if let Some(sat) = sat {
solid.set_sat_document(&sat);
}
if let Some(wires) = wires.filter(|wires| !wires.is_empty()) {
solid.wires = wires;
}
}
}
@@ -1408,14 +1444,23 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
move |result| Message::ObjImportFinished(tab_id, path, result),)
}
fn sync_view_state_for_save(&mut self, i: usize) {
self.sync_vport_display(i);
if self.tabs[i].active_block_edit.is_none() {
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen =
self.tabs[i].scene.camera_generation;
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn prepare_native_save(&mut self, i: usize) {
self.sync_vport_display(i);
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_to_acis(i);
self.sync_solid_models_for_save(i);
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1470,16 +1515,120 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
#[cfg(target_arch = "wasm32")]
fn stamp_thumbnail(&mut self, i: usize, version: acadrust::DxfVersion) {
let scene = &self.tabs[i].scene;
let preview = crate::io::thumbnail::from_snapshot(
&scene.entity_wires(),
&scene.camera.borrow(),
scene.bg_color,
version >= acadrust::DxfVersion::AC1027,
self.vp_size,
);
self.tabs[i].scene.document.preview = preview;
pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task<Message> {
let Some(pending) = self.pending_web_thumbnail_save.take() else {
return Task::none();
};
iced::window::latest()
.then(|window| match window {
Some(window) => iced::window::screenshot(window).map(Some),
None => Task::done(None),
})
.map(move |screenshot| Message::WebSaveScreenshot {
tab_id: pending.tab_id,
filename: pending.filename.clone(),
ext: pending.ext.clone(),
version: pending.version,
bounds: Some(pending.bounds),
screenshot,
})
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task<Message> {
let Some(pending) = self.pending_native_thumbnail_save.take() else {
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == pending.tab_id) else {
self.thumbnail_capture_clean = false;
return Task::none();
};
self.queue_native_save(
i,
pending.path,
pending.version,
pending.purpose,
pending.continuation,
pending.set_current_path,
pending.check_external_change,
)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_screenshot(
&mut self,
tab_id: u64,
filename: String,
ext: String,
version: acadrust::DxfVersion,
bounds: Option<iced::Rectangle>,
screenshot: Option<iced::window::Screenshot>,
) -> Task<Message> {
self.thumbnail_capture_clean = false;
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
return Task::none();
};
let preview = screenshot.as_ref().and_then(|screenshot| {
bounds.and_then(|bounds| {
crate::io::thumbnail::from_screenshot(
screenshot,
bounds,
version >= acadrust::DxfVersion::AC1027,
)
})
});
if let Some(preview) = preview {
self.tabs[i].scene.document.preview = Some(preview);
}
let mut recent_task = Task::none();
let saved = match crate::io::save_to_bytes(
&self.tabs[i].scene.document,
&ext,
version,
) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
self.tabs[i].dirty = false;
self.tabs[i].recovery_save_as_required = false;
recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
true
}
Err(error) => {
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
false
}
};
if self.save_dialog_for_unsaved {
if saved {
if let Some(crate::app::PendingClose::Tab(index)) = self.pending_close.take() {
let continuation = self.update(Message::TabClose(index));
let rest = self.continue_tab_close_queue();
return Task::batch([recent_task, continuation, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([recent_task, retry]);
}
}
recent_task
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1494,11 +1643,27 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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]
@@ -1516,6 +1681,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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
@@ -1540,6 +1708,33 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
});
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();
}
@@ -1566,6 +1761,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
});
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)) => {
@@ -1579,32 +1777,15 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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_key = (purpose != crate::app::SavePurpose::Autosave).then(|| {
let scene = &self.tabs[i].scene;
crate::app::ThumbnailCacheKey {
epoch,
camera_generation,
bg_color: scene.bg_color.map(f32::to_bits),
png: version >= acadrust::DxfVersion::AC1027,
viewport: [self.vp_size.0.to_bits(), self.vp_size.1.to_bits()],
}
let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab)
.then_some(version >= acadrust::DxfVersion::AC1027);
let capture_bounds = thumbnail.and_then(|_| {
crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
)
});
let thumbnail = if thumbnail_key == self.tabs[i].thumbnail_cache_key {
None
} else {
thumbnail_key.map(|key| {
let scene = &self.tabs[i].scene;
(
scene.entity_wires(),
scene.camera.borrow().clone(),
scene.bg_color,
key.png,
self.vp_size,
)
})
};
let clone_started = iced::time::Instant::now();
let mut snapshot = self.tabs[i].scene.document.clone();
let snapshot = self.tabs[i].scene.document.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
if crate::perf::enabled() {
crate::perf_record!(
@@ -1650,6 +1831,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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 {
@@ -1664,7 +1848,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
set_current_path,
purpose,
continuation,
thumbnail_key,
refreshed_preview: None,
result: Err(error),
}
@@ -1674,65 +1857,97 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
};
let worker_path = path.clone();
Task::perform(
async move {
let (result, refreshed_preview) = std::thread::spawn(move || {
let mut refreshed_preview = None;
if let Some((wires, camera, bg_color, png, viewport)) = thumbnail {
let started = iced::time::Instant::now();
snapshot.preview = crate::io::thumbnail::from_snapshot(
&wires,
&camera,
bg_color,
png,
viewport,
);
if crate::perf::enabled() {
crate::perf_record!(
"[perf] save-thumbnail {:.1}ms wires={}",
started.elapsed().as_secs_f64() * 1000.0,
wires.len(),
);
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,
);
}
}
refreshed_preview = Some(snapshot.preview.clone());
}
let result = crate::io::save_owned_as_version_atomic(
snapshot,
&worker_path,
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,
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,
thumbnail_key,
refreshed_preview,
result,
}
},
Message::SaveFinished,
)
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"))]
@@ -1836,7 +2051,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
if let Some(preview) = outcome.refreshed_preview {
self.tabs[i].scene.document.preview = preview;
}
self.tabs[i].thumbnail_cache_key = outcome.thumbnail_key;
}
let mut tasks = Vec::new();
match outcome.purpose {
@@ -1850,10 +2064,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.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());
self.recent_thumbs.remove(&outcome.path);
if let Some(previous) = outcome.previous_autosave {
if previous != outcome.path {
let _ = std::fs::remove_file(previous);
@@ -1865,8 +2078,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
if outcome.purpose == crate::app::SavePurpose::SaveAs {
self.tabs[i].recovery_save_as_required = false;
}
tasks.push(self.push_recent(outcome.path.clone()));
}
tasks.push(self.push_recent(outcome.path.clone()));
self.refresh_native_edit_guard_after_save(
i,
&outcome.path,
@@ -2064,7 +2277,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
// once after the destination/version is known.
#[cfg(target_arch = "wasm32")]
{
self.sync_vport_display(i);
self.sync_view_state_for_save(i);
self.stamp_header_sysvars(i);
}
// Native: save straight to the known path. Web has no path
@@ -2199,62 +2412,38 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
#[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_to_acis(i);
self.stamp_thumbnail(i, version);
let mut recent_task = Task::none();
let saved = match crate::io::save_to_bytes(
&self.tabs[i].scene.document,
ext,
version,
) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
self.tabs[i].dirty = false;
self.tabs[i].recovery_save_as_required = false;
recent_task = Task::perform(
async move {
crate::io::web_recent::store(
&path.to_string_lossy(),
&bytes,
)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
self.command_line.push_output(crate::tf!("Saved: {filename}").as_ref());
true
}
Err(e) => {
self.command_line.push_error(crate::tf!("Save failed: {e}").as_ref());
false
}
self.sync_solid_models_for_save(i);
let tab_id = self.tabs[i].id;
let bounds = crate::ui::wrap_bar::dropdown_bounds(
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
);
let Some(bounds) = bounds else {
return Task::batch([
close,
Task::done(Message::WebSaveScreenshot {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds: None,
screenshot: None,
}),
]);
};
// Continue a pending tab close.
if self.save_dialog_for_unsaved {
if saved {
if let Some(crate::app::PendingClose::Tab(idx)) =
self.pending_close.take()
{
let cont = self.update(Message::TabClose(idx));
let rest = self.continue_tab_close_queue();
return Task::batch([close, recent_task, cont, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([close, recent_task, retry]);
}
}
Task::batch([close, recent_task])
self.pending_web_thumbnail_save = Some(
crate::app::PendingWebThumbnailSave {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds,
},
);
self.thumbnail_capture_clean = true;
close
}
}
@@ -2365,6 +2554,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
{
continue;
}
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let target = self.autosave_target(i);
tasks.push(self.queue_native_save(
+24
View File
@@ -6320,9 +6320,33 @@ impl OpenCADStudio {
Message::AutoSave => self.on_autosave(),
Message::ThumbnailCaptureFrame => self.on_thumbnail_capture_frame(),
Message::ThumbnailCaptureFinished => {
self.thumbnail_capture_clean = false;
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFinished(outcome) => self.on_save_finished(outcome),
#[cfg(target_arch = "wasm32")]
Message::WebSaveScreenshot {
tab_id,
filename,
ext,
version,
bounds,
screenshot,
} => self.on_web_save_screenshot(
tab_id,
filename,
ext,
version,
bounds,
screenshot,
),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),
+2
View File
@@ -809,6 +809,7 @@ impl OpenCADStudio {
// render mode; the model-layout tab style is untouched.
if self.tabs[i].scene.set_active_viewport_render_mode(mode) {
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("Viewport visual style: {label}").as_ref());
return Task::none();
@@ -827,6 +828,7 @@ impl OpenCADStudio {
// Re-upload face3d fills on the next frame — the render
// pipeline keys its upload cache off `geometry_epoch`.
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("Visual style: {label}").as_ref());
Task::none()
+42 -4
View File
@@ -32,6 +32,8 @@ use viewcube::{viewcube_nav_controls, viewcube_ucs_picker, UCS_PICKER_W};
// the `view::` path as before the split.
pub(in crate::app) use overlay::{MTEXT_TEXT_ID, TEXT_INLINE_ID};
pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bounds";
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX;
/// The desk shown around the sheet, as the widget wants it. One definition,
/// shared with the renderer that clears the sheet viewport to the same thing.
@@ -184,6 +186,7 @@ impl OpenCADStudio {
pub fn view_main(&self) -> Element<'_, Message> {
let i = self.active_tab;
let tab = &self.tabs[i];
let thumbnail_capture_clean = self.thumbnail_capture_clean;
let theme_text = self.active_theme.palette().background.base.text;
let viewcube_text_color = [
theme_text.r,
@@ -229,8 +232,10 @@ impl OpenCADStudio {
let (vw, vh) = tab.scene.selection.borrow().vp_size;
tab.scene.active_model_tile_bounds(vw, vh).width
};
let viewcube_visible =
self.show_viewcube && !tab.is_start && viewcube_has_room(render_bar_w, active_vp_w);
let viewcube_visible = self.show_viewcube
&& !thumbnail_capture_clean
&& !tab.is_start
&& viewcube_has_room(render_bar_w, active_vp_w);
// Start tab: render welcome page in place of the viewport.
// Surrounding chrome (tab bar, status bar) stays; the welcome widget
// returned here also flags the rest of `view` to skip drawing-only
@@ -258,6 +263,7 @@ impl OpenCADStudio {
shader(ViewportPane::model(
&tab.scene,
viewcube_visible,
!thumbnail_capture_clean,
viewport_render_mode,
viewcube_text_color,
))
@@ -276,6 +282,7 @@ impl OpenCADStudio {
// mouse_areas' hover state and drops their move events).
let scene = &tab.scene;
let show_viewcube = viewcube_visible;
let show_interaction = !thumbnail_capture_clean;
let render_mode = viewport_render_mode;
let size_probe: Element<'_, Message> = responsive(move |size| {
{
@@ -293,6 +300,7 @@ impl OpenCADStudio {
shader(ViewportPane::for_pane(
scene,
show_viewcube,
show_interaction,
render_mode,
idx,
viewcube_text_color,
@@ -868,6 +876,20 @@ impl OpenCADStudio {
.height(Fill)]
.width(Fill)
.height(Fill)
} else if thumbnail_capture_clean {
let capture_bg = if is_paper { PAPER_SPACE_BACKGROUND } else { bg_color };
stack![
container(Space::new())
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(capture_bg)),
..Default::default()
})
.width(Fill)
.height(Fill),
viewport_3d,
]
.width(Fill)
.height(Fill)
} else if is_paper {
// Keep the grid above the opaque GPU sheet and below interaction UI.
stack![
@@ -902,6 +924,7 @@ impl OpenCADStudio {
.height(Fill)
};
if !thumbnail_capture_clean {
// Per-pane input pane_grid goes ABOVE the crosshair overlay so it
// receives mouse events (the overlay's `Hidden` cursor would otherwise
// starve any layer beneath it). The controls bar is pushed on top of it.
@@ -1481,6 +1504,7 @@ impl OpenCADStudio {
viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas));
}
}
}
// Docked side panels (Properties, block palette, future palettes) live
// in an ordered vertical stack on the left/right edge of the drawing
@@ -1535,7 +1559,13 @@ impl OpenCADStudio {
if let Some(e) = left_edge {
parts.push(e);
}
parts.push(viewport_stack.into());
parts.push(
crate::ui::wrap_bar::PosReport::new(
VIEWPORT_CAPTURE_BOUNDS_ID,
viewport_stack,
)
.into(),
);
if let Some(e) = right_edge {
parts.push(e);
}
@@ -1712,7 +1742,9 @@ impl OpenCADStudio {
&self.history_content,
self.win_size.1,
);
let center_stack: Element<'_, Message> = if tab.is_start {
let center_stack: Element<'_, Message> = if thumbnail_capture_clean {
workspace
} else if tab.is_start {
column![
workspace,
iced::widget::container(command_line)
@@ -2096,6 +2128,11 @@ impl OpenCADStudio {
} else {
Subscription::none()
};
let thumbnail_capture = if self.thumbnail_capture_clean {
window::frames().map(|_| Message::ThumbnailCaptureFrame)
} else {
Subscription::none()
};
// Blink the MText preview caret while the editor is open.
let caret_blink = if self.mtext_editor.is_some() {
iced::time::every(std::time::Duration::from_millis(530))
@@ -2154,6 +2191,7 @@ impl OpenCADStudio {
grip_dwell,
hover_dwell,
nav_settle,
thumbnail_capture,
caret_blink,
web_fonts,
autosave,