diff --git a/src/app/document.rs b/src/app/document.rs index 09048da4f..5893af529 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -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, /// 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, diff --git a/src/app/mod.rs b/src/app/mod.rs index 71afb4649..9e5877083 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, + /// Hides drawing UI overlays for one thumbnail capture frame. + thumbnail_capture_clean: bool, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: Option, + #[cfg(target_arch = "wasm32")] + pending_web_thumbnail_save: Option, // ── 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, refreshed_preview: Option>, 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, + screenshot: Option, + }, /// 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(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 20cb17008..5ea4f4e49 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 = 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 { 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 { } #[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 { + 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 { + 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, + screenshot: Option, + ) -> Task { + 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 { check_external_change: bool, ) -> Task { 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 { 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 { }); 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 { }); 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 { 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 { 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 { 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 { } }; 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| { + 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 { 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 { .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 { 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 { // 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 { #[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 { { 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( diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e5fb0be1c..66ed2b608 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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(), diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 35bcecbda..2409b6a23 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bfbce4288..a9c104fa1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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, diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index 8b485053b..28272b409 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -1,9 +1,7 @@ //! DWG preview thumbnails. //! -//! - [`from_snapshot`] rasterizes the drawing exactly as it is framed on screen — -//! the live camera's pan / zoom / rotation, only the currently visible region, -//! not the whole extent — into a small [`acadrust::Preview`] embedded on save -//! so OCS drawings show a thumbnail in file browsers and other CAD apps. +//! - [`from_screenshot`] crops the visible drawing area into a small +//! [`acadrust::Preview`] embedded on save. //! - [`read_handle`] / [`extract_to_png`] read a DWG's *embedded* preview back //! for the Start page and the OS file-manager thumbnailer. Extraction lives in //! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS @@ -11,67 +9,55 @@ use acadrust::{Preview, PreviewFormat}; use iced::Rectangle; -use image::{ImageFormat, Rgb, RgbImage}; +use image::{ImageFormat, RgbImage}; use std::io::Cursor; -use crate::scene::WireModel; -use crate::scene::view::camera::Camera; +/// Build a preview from the visible drawing area of an Iced window screenshot. +pub fn from_screenshot( + screenshot: &iced::window::Screenshot, + bounds: Rectangle, + png: bool, +) -> Option { + let scale = screenshot.scale_factor; + if !scale.is_finite() || scale <= 0.0 || bounds.width <= 0.0 || bounds.height <= 0.0 { + return None; + } + + let width = screenshot.size.width as f32; + let height = screenshot.size.height as f32; + let left = (bounds.x * scale).floor().clamp(0.0, width) as u32; + let top = (bounds.y * scale).floor().clamp(0.0, height) as u32; + let right = ((bounds.x + bounds.width) * scale) + .ceil() + .clamp(0.0, width) as u32; + let bottom = ((bounds.y + bounds.height) * scale) + .ceil() + .clamp(0.0, height) as u32; + if right <= left || bottom <= top { + return None; + } + + let cropped = screenshot + .crop(Rectangle { + x: left, + y: top, + width: right - left, + height: bottom - top, + }) + .ok()?; + let rgba = image::RgbaImage::from_raw( + cropped.size.width, + cropped.size.height, + cropped.rgba.to_vec(), + )?; + let (cw, ch) = canvas_dims(cropped.size.width as f64 / cropped.size.height as f64); + let resized = image::imageops::resize(&rgba, cw, ch, image::imageops::FilterType::Triangle); + encode(image::DynamicImage::ImageRgba8(resized).into_rgb8(), png) +} /// Longest edge of the generated thumbnail, in pixels. const MAX_DIM: u32 = 256; -/// Build a preview matching what is currently on screen: the drawing's wires -/// projected through the live camera into a `viewport`-aspect canvas, so the -/// thumbnail is the visible framing (pan / zoom / rotation, only the on-screen -/// region), not the whole extent. `viewport` is the model pane's pixel size. -/// `None` when the drawing is empty (clears any stale preview). -/// -/// `png` picks the encoding: a line drawing on a flat background is almost all -/// one colour, so a **PNG** collapses to a few KB where the uncompressed -/// **BMP/DIB** stays ~180 KB at 256². PNG previews are only valid from R2013 -/// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB. -/// -/// Native background saves retain -/// the resident wire `Arc` and a small camera copy, then run the point scan and -/// image encoding on the save worker instead of blocking the UI thread. -pub fn from_snapshot( - wires: &[WireModel], - camera: &Camera, - bg_color: [f32; 4], - png: bool, - viewport: (f32, f32), -) -> Option { - if wires.is_empty() { - return None; - } - let (vw, vh) = viewport; - if !(vw > 0.0 && vh > 0.0) { - return None; - } - - // Canvas keeps the viewport's aspect so the framing is undistorted; longest - // edge = MAX_DIM. Projecting with the canvas rectangle as the camera bounds - // makes `project` return pixel coordinates already in canvas space. - let (cw, ch) = canvas_dims((vw / vh) as f64); - let bounds = Rectangle { - x: 0.0, - y: 0.0, - width: cw as f32, - height: ch as f32, - }; - #[cfg(not(target_arch = "wasm32"))] - { - rasterize_snapshot_parallel(wires, camera, bounds, cw, ch, bg_color, png) - } - #[cfg(target_arch = "wasm32")] - { - rasterize(wires, cw, ch, bg_color, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|s| (s.x.round() as i32, s.y.round() as i32)) - }) - } -} - /// Canvas dimensions for an aspect ratio, longest edge = [`MAX_DIM`]. fn canvas_dims(aspect: f64) -> (u32, u32) { if aspect >= 1.0 { @@ -81,184 +67,7 @@ fn canvas_dims(aspect: f64) -> (u32, u32) { } } -#[cfg(not(target_arch = "wasm32"))] -fn rasterize_snapshot_parallel( - wires: &[WireModel], - camera: &Camera, - bounds: Rectangle, - cw: u32, - ch: u32, - bg: [f32; 4], - png: bool, -) -> Option { - use crate::par::prelude::*; - - let total_segments: usize = wires - .iter() - .map(|wire| wire.points.len().saturating_sub(1)) - .sum(); - if total_segments < 100_000 { - return rasterize(wires, cw, ch, bg, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|point| (point.x.round() as i32, point.y.round() as i32)) - }); - } - - let task_count = (rayon::current_num_threads().max(1) * 2).min(total_segments); - let target_work = total_segments.div_ceil(task_count).max(1); - let mut tasks: Vec> = Vec::with_capacity(task_count); - let mut task = Vec::new(); - let mut task_work = 0usize; - for (wire_index, wire) in wires.iter().enumerate() { - let segment_count = wire.points.len().saturating_sub(1); - let mut start = 0usize; - while start < segment_count { - let take = (target_work - task_work).min(segment_count - start); - task.push((wire_index, start, start + take)); - task_work += take; - start += take; - if task_work == target_work { - tasks.push(std::mem::take(&mut task)); - task_work = 0; - } - } - } - if !task.is_empty() { - tasks.push(task); - } - - let layers: Vec> = tasks - .par_iter() - .map(|task| { - let mut pixels = vec![u32::MAX; (cw * ch) as usize]; - for &(wire_index, start, end) in task { - let wire = &wires[wire_index]; - let color = to_rgb(wire.color); - let packed = - color[0] as u32 | (color[1] as u32) << 8 | (color[2] as u32) << 16; - let mut previous = projected_wire_point(wire, start, camera, bounds); - for index in start + 1..=end { - let current = projected_wire_point(wire, index, camera, bounds); - if let (Some(a), Some(b)) = (previous, current) { - draw_line_layer(&mut pixels, cw, ch, a, b, packed); - } - previous = current; - } - } - pixels - }) - .collect(); - - let mut image = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); - for layer in layers { - for (pixel, packed) in image.pixels_mut().zip(layer) { - if packed != u32::MAX { - *pixel = Rgb([ - packed as u8, - (packed >> 8) as u8, - (packed >> 16) as u8, - ]); - } - } - } - encode(image, png) -} - -#[cfg(not(target_arch = "wasm32"))] -fn projected_wire_point( - wire: &WireModel, - index: usize, - camera: &Camera, - bounds: Rectangle, -) -> Option<(i32, i32)> { - let point = wire.points.get(index)?; - if !point[0].is_finite() || !point[1].is_finite() { - return None; - } - let (x, y, z) = abs_xyz(wire, index, point); - camera - .project(glam::DVec3::new(x, y, z), bounds) - .map(|screen| (screen.x.round() as i32, screen.y.round() as i32)) -} - -#[cfg(not(target_arch = "wasm32"))] -fn draw_line_layer( - pixels: &mut [u32], - width: u32, - height: u32, - (x0, y0): (i32, i32), - (x1, y1): (i32, i32), - color: u32, -) { - let (width, height) = (width as i32, height as i32); - let Some(((mut x0, mut y0), (x1, y1))) = - clip_line_to_rect((x0, y0), (x1, y1), width, height) - else { - return; - }; - let dx = (x1 - x0).abs(); - let dy = -(y1 - y0).abs(); - let sx = if x0 < x1 { 1 } else { -1 }; - let sy = if y0 < y1 { 1 } else { -1 }; - let mut error = dx + dy; - loop { - if x0 >= 0 && x0 < width && y0 >= 0 && y0 < height { - pixels[y0 as usize * width as usize + x0 as usize] = color; - } - if x0 == x1 && y0 == y1 { - break; - } - let twice = 2 * error; - if twice >= dy { - error += dy; - x0 += sx; - } - if twice <= dx { - error += dx; - y0 += sy; - } - } -} - -/// Rasterize `wires` onto a `bg`-filled `cw`×`ch` canvas, placing each vertex -/// with `project` (world XYZ → canvas pixel, `None` = not projectable), and -/// encode the result. A `None` from `project` breaks the polyline run, as does a -/// NaN separator, so off-screen / clipped segments simply don't draw. -fn rasterize( - wires: &[WireModel], - cw: u32, - ch: u32, - bg: [f32; 4], - png: bool, - project: impl Fn(f64, f64, f64) -> Option<(i32, i32)>, -) -> Option { - let mut img = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); - for w in wires { - let col = Rgb(to_rgb(w.color)); - let mut prev: Option<(i32, i32)> = None; - for (i, p) in w.points.iter().enumerate() { - if !p[0].is_finite() || !p[1].is_finite() { - prev = None; // NaN separator breaks the run - continue; - } - let (x, y, z) = abs_xyz(w, i, p); - let cur = project(x, y, z); - if let (Some(a), Some(b)) = (prev, cur) { - draw_line(&mut img, a, b, col); - } - prev = cur; - } - } - encode(img, png) -} - -/// Encode the canvas. PNG for R2013+ targets (few KB); else a BMP → DIB (no -/// 14-byte BITMAPFILEHEADER, which the DWG preview container doesn't carry). -/// The BMP is an 8-bit **RLE8**-compressed DIB — a line drawing on a flat -/// background is a few distinct colours with long single-colour runs, so it -/// collapses from the ~180 KB of a 24-bit DIB to a handful of KB. A view with -/// more than 256 distinct colours (rare) can't be palettised, so it falls back -/// to the 24-bit uncompressed DIB. +/// Encode PNG for R2013+; older targets receive a BMP/DIB. fn encode(img: RgbImage, png: bool) -> Option { if png { let mut buf = Cursor::new(Vec::new()); @@ -347,25 +156,6 @@ fn bmp24_dib(img: &RgbImage) -> Option> { (bmp.len() > 14).then(|| bmp[14..].to_vec()) } -/// Absolute world XYZ of vertex `i`, reconstructing the double-single residual. -#[inline] -fn abs_xyz(w: &WireModel, i: usize, p: &[f32; 3]) -> (f64, f64, f64) { - let (lx, ly, lz) = w - .points_low - .get(i) - .map_or((0.0, 0.0, 0.0), |l| (l[0] as f64, l[1] as f64, l[2] as f64)); - (p[0] as f64 + lx, p[1] as f64 + ly, p[2] as f64 + lz) -} - -#[inline] -fn to_rgb(c: [f32; 4]) -> [u8; 3] { - [ - (c[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (c[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (c[2].clamp(0.0, 1.0) * 255.0).round() as u8, - ] -} - /// Read a DWG's embedded preview and write it as a PNG at `output`, scaled so /// its longest edge is at most `size`. Returns `false` on any failure (no /// preview, undecodable, write error) so the OS thumbnailer falls back to a @@ -399,14 +189,6 @@ pub fn read_handle(path: &std::path::Path) -> Option WireModel { - WireModel { - points: pts.iter().map(|&[x, y]| [x, y, 0.0]).collect(), - color, - ..Default::default() - } - } - /// Prepend a `BITMAPFILEHEADER` so `image` can decode the DIB. Mirrors the /// palette-aware offset the shared `dwg_thumbnailer::dib_to_bmp` computes. fn dib_to_bmp(dib: &[u8]) -> Vec { @@ -429,35 +211,13 @@ mod tests { assert_eq!(canvas_dims(0.5), (MAX_DIM / 2, MAX_DIM)); // tall } - #[test] - fn rasterize_draws_a_valid_non_blank_dib() { - let bg = [0.1, 0.1, 0.1, 1.0]; - // A closed square (connected polyline) in a distinct colour. - let sq = wire( - &[[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0], [10.0, 10.0]], - [1.0, 0.0, 0.0, 1.0], - ); - // Trivial projector: world XY straight to pixels (Y flipped), z ignored. - let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, |x, y, _| { - Some((x.round() as i32, (MAX_DIM as f64 - y).round() as i32)) - }) - .expect("some preview"); - assert_eq!(p.format, PreviewFormat::Bmp); - // DIB starts with a 40-byte BITMAPINFOHEADER. - assert_eq!(&p.data[0..4], &40u32.to_le_bytes()); - let img = image::load_from_memory(&dib_to_bmp(&p.data)).expect("decodes").to_rgb8(); - assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); - let bg_px = to_rgb(bg); - assert!(img.pixels().any(|px| px.0 != bg_px), "nothing drawn"); - assert!(img.pixels().any(|px| px.0[0] > 128 && px.0[1] < 64), "square not red"); - } - #[test] fn rle8_bmp_is_8bit_compressed_and_round_trips() { - let bg = [1.0, 1.0, 1.0, 1.0]; - let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]); - let proj = |x: f64, y: f64, _z: f64| Some((x.round() as i32, y.round() as i32)); - let bmp = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, proj).unwrap(); + let mut image = RgbImage::from_pixel(MAX_DIM, MAX_DIM, image::Rgb([255, 255, 255])); + for i in 10..90 { + image.put_pixel(i, i, image::Rgb([0, 0, 0])); + } + let bmp = encode(image, false).unwrap(); assert_eq!(bmp.format, PreviewFormat::Bmp); // 8-bit, BI_RLE8. assert_eq!(u16::from_le_bytes([bmp.data[14], bmp.data[15]]), 8, "bitcount"); @@ -476,90 +236,10 @@ mod tests { #[test] fn png_preview_decodes() { - let bg = [1.0, 1.0, 1.0, 1.0]; - let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]); - let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, true, |x, y, _| { - Some((x.round() as i32, y.round() as i32)) - }) - .unwrap(); + let image = RgbImage::from_pixel(MAX_DIM, MAX_DIM, image::Rgb([255, 255, 255])); + let p = encode(image, true).unwrap(); assert_eq!(p.format, PreviewFormat::Png); let img = image::load_from_memory_with_format(&p.data, ImageFormat::Png).expect("png decodes"); assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); } } - -/// Bresenham line, clipped to the image bounds. -fn draw_line(img: &mut RgbImage, (x0, y0): (i32, i32), (x1, y1): (i32, i32), col: Rgb) { - let (w, h) = (img.width() as i32, img.height() as i32); - let Some(((mut x0, mut y0), (x1, y1))) = - clip_line_to_rect((x0, y0), (x1, y1), w, h) - else { - return; - }; - let dx = (x1 - x0).abs(); - let dy = -(y1 - y0).abs(); - let sx = if x0 < x1 { 1 } else { -1 }; - let sy = if y0 < y1 { 1 } else { -1 }; - let mut err = dx + dy; - loop { - if x0 >= 0 && x0 < w && y0 >= 0 && y0 < h { - img.put_pixel(x0 as u32, y0 as u32, col); - } - if x0 == x1 && y0 == y1 { - break; - } - let e2 = 2 * err; - if e2 >= dy { - err += dy; - x0 += sx; - } - if e2 <= dx { - err += dx; - y0 += sy; - } - } -} - -fn clip_line_to_rect( - (x0, y0): (i32, i32), - (x1, y1): (i32, i32), - width: i32, - height: i32, -) -> Option<((i32, i32), (i32, i32))> { - if width <= 0 || height <= 0 { - return None; - } - let (x0, y0, x1, y1) = (x0 as f64, y0 as f64, x1 as f64, y1 as f64); - let (dx, dy) = (x1 - x0, y1 - y0); - let mut enter = 0.0f64; - let mut leave = 1.0f64; - for (p, q) in [ - (-dx, x0), - (dx, width.saturating_sub(1) as f64 - x0), - (-dy, y0), - (dy, height.saturating_sub(1) as f64 - y0), - ] { - if p == 0.0 { - if q < 0.0 { - return None; - } - continue; - } - let ratio = q / p; - if p < 0.0 { - enter = enter.max(ratio); - } else { - leave = leave.min(ratio); - } - if enter > leave { - return None; - } - } - let point = |t: f64| { - ( - (x0 + t * dx).round().clamp(0.0, width.saturating_sub(1) as f64) as i32, - (y0 + t * dy).round().clamp(0.0, height.saturating_sub(1) as f64) as i32, - ) - }; - Some((point(enter), point(leave))) -} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 75d15dcb0..b27c60613 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -315,7 +315,9 @@ impl Scene { self.camera_generation = saved_generation; if let Some((min, max)) = refreshed { - self.camera.borrow_mut().fit_depth_to_bounds(min, max); + self.camera + .borrow_mut() + .fit_depth_to_bounds_f64(min, max); self.projection_bounds_epoch.set(self.geometry_epoch); } } @@ -672,11 +674,7 @@ impl Scene { // The saved-view convention uses a 24 mm vertical aperture. This is // the inverse of the projection path's documented // `distance = view_height * lens / 24` relation. - let fov_y = if perspective { - 2.0 * (12.0 / lens_length).atan() - } else { - 45.0_f32.to_radians() - }; + let fov_y = 2.0 * (12.0 / lens_length).atan(); let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001); Some(Camera { target, @@ -729,9 +727,9 @@ impl Scene { let view_dir = cam.rotation * glam::Vec3::Z; let view_height = cam.ortho_size() * 2.0; let target_wcs = acadrust::types::Vector3 { - x: (cam.target.x as f64) + [0.0_f64; 3][0], - y: (cam.target.y as f64) + [0.0_f64; 3][1], - z: (cam.target.z as f64) + [0.0_f64; 3][2], + x: cam.target.x, + y: cam.target.y, + z: cam.target.z, }; entry.lower_left = lower_left; entry.upper_right = upper_right; @@ -1186,9 +1184,11 @@ impl Scene { if !ok { return; } - let min = glam::Vec3::new(lo.x as f32, lo.y as f32, lo.z as f32); - let max = glam::Vec3::new(hi.x as f32, hi.y as f32, hi.z as f32); - self.camera.borrow_mut().fit_depth_to_bounds(min, max); + let min = glam::DVec3::new(lo.x, lo.y, lo.z); + let max = glam::DVec3::new(hi.x, hi.y, hi.z); + self.camera + .borrow_mut() + .fit_depth_to_bounds_f64(min, max); } fn fit_paper_space_extents(&mut self) { @@ -1557,7 +1557,7 @@ impl Scene { if index == active { tile.camera = live_camera.clone(); } else { - tile.camera.fit_to_bounds(min, max, aspect); + tile.camera.fit_to_bounds_f64(min, max, aspect); } } if let Some(aspect) = aspects.get(active) { diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index d8c24af57..57792eddf 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -42,6 +42,8 @@ pub(crate) fn body_transform( values.extend_from_slice(&components[..len]); } else if let Some(value) = token.as_float() { values.push(value); + } else if let Some(value) = token.as_integer() { + values.push(value as f64); } else if let Some(text) = token.as_string() { for word in text.split_ascii_whitespace() { let Ok(value) = word.parse::() else { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 6d92855f5..9c638f1a0 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -924,6 +924,7 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, viewport: Option, + tint_selected: bool, ) -> Vec { let layer_hidden = |layer: &str| { self.document @@ -1098,7 +1099,7 @@ impl Scene { } } } - if self.selected.contains(&handle) { + if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } let d = depth_map.get(&handle.value()).map_or(0.0, |d| d[0]); @@ -1115,7 +1116,7 @@ impl Scene { models.extend(self.instanced_hatch_models( target_block, hatch_bg, - true, + tint_selected, frozen, annotation_scale_handle, all_visible, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1f7e6f678..e141c8141 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5674,14 +5674,19 @@ impl Scene { arc } + #[cfg(test)] pub(super) fn hatch_models_arc(&self) -> Arc> { + self.hatch_models_arc_for_view(true) + } + + fn hatch_models_arc_for_view(&self, tint_selected: bool) -> Arc> { // Hatch models bake the selection tint (issue #71), so they depend on // the *selected set* — but NOT on hover. Keying on `selection_generation` // (which also bumps on every hover) made each hover-over a new entity // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. - let sel_sig = self.selected_hatch_sig(); + let sel_sig = if tint_selected { self.selected_hatch_sig() } else { 0 }; let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { @@ -5728,6 +5733,7 @@ impl Scene { scale, self.annotation_all_visible(), None, + tint_selected, )); self.hatch_cache.borrow_mut().insert( key, @@ -6324,9 +6330,10 @@ impl Scene { &self, viewport: Handle, frozen: &HashSet, + tint_selected: bool, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { - return self.hatch_models_arc(); + return self.hatch_models_arc_for_view(tint_selected); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); @@ -6336,7 +6343,7 @@ impl Scene { ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); - let sel = self.selected_hatch_sig(); + let sel = if tint_selected { self.selected_hatch_sig() } else { 0 }; if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); @@ -6348,6 +6355,7 @@ impl Scene { scale, all_visible, Some(viewport), + tint_selected, )); self.frozen_hatch_cache .borrow_mut() diff --git a/src/scene/paper.rs b/src/scene/paper.rs index a3c11612f..2ed40163b 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -204,7 +204,18 @@ impl Scene { Arc>, Arc>, ) { - let selected = self.selected_hatch_sig(); + self.paper_sheet_render_models_for_view(true) + } + + pub(super) fn paper_sheet_render_models_for_view( + &self, + tint_selected: bool, + ) -> ( + Arc>, + Arc>, + Arc>, + ) { + let selected = if tint_selected { self.selected_hatch_sig() } else { 0 }; let reuse = { let cache = self.paper_sheet_render_cache.borrow(); if let Some(cache) = cache.get(&self.current_layout) { @@ -259,7 +270,11 @@ impl Scene { if let Some(sheet) = self.paper_sheet_fill() { hatches.push(sheet); } - hatches.extend(self.paper_canvas_hatches().iter().cloned()); + hatches.extend( + self.paper_canvas_hatches(tint_selected) + .iter() + .cloned(), + ); let hatches = Arc::new(hatches); let wipeouts = self.paper_canvas_wipeouts(); let images = self.paper_sheet_images(); @@ -381,7 +396,7 @@ impl Scene { /// entity handle) rather than the already-flattened arc — the /// flattened arc carries pattern names, not handles, so filtering /// there is unreliable. - pub fn paper_canvas_hatches(&self) -> Arc> { + fn paper_canvas_hatches(&self, tint_selected: bool) -> Arc> { let layout_block = self.current_layout_block_handle(); let layer_hidden = |layer: &str| { self.document @@ -455,7 +470,7 @@ impl Scene { } } } - if self.selected.contains(&handle) { + if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } models.push(m); diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 8fbbe1d1e..48de4322f 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -61,7 +61,7 @@ pub struct Camera { /// that span, clipping the drawing (#473). Re-projecting the box each frame /// keeps near/far exactly as deep as the current view needs. `None` falls /// back to `depth_half_range`. - pub model_bounds: Option<(Vec3, Vec3)>, + pub model_bounds: Option<(DVec3, DVec3)>, } impl Default for Camera { @@ -435,25 +435,24 @@ impl Camera { if let Some((min, max)) = self.model_bounds { let corners = [ - Vec3::new(min.x, min.y, min.z), - Vec3::new(max.x, min.y, min.z), - Vec3::new(min.x, max.y, min.z), - Vec3::new(max.x, max.y, min.z), - Vec3::new(min.x, min.y, max.z), - Vec3::new(max.x, min.y, max.z), - Vec3::new(min.x, max.y, max.z), - Vec3::new(max.x, max.y, max.z), + DVec3::new(min.x, min.y, min.z), + DVec3::new(max.x, min.y, min.z), + DVec3::new(min.x, max.y, min.z), + DVec3::new(max.x, max.y, min.z), + DVec3::new(min.x, min.y, max.z), + DVec3::new(max.x, min.y, max.z), + DVec3::new(min.x, max.y, max.z), + DVec3::new(max.x, max.y, max.z), ]; - let mut new_min = Vec3::splat(f32::INFINITY); - let mut new_max = Vec3::splat(f32::NEG_INFINITY); + let mut new_min = DVec3::splat(f64::INFINITY); + let mut new_max = DVec3::splat(f64::NEG_INFINITY); for corner in corners { let transformed = transform.apply(acadrust::types::Vector3::new( - corner.x as f64, - corner.y as f64, - corner.z as f64, + corner.x, + corner.y, + corner.z, )); - let transformed = - Vec3::new(transformed.x as f32, transformed.y as f32, transformed.z as f32); + let transformed = DVec3::new(transformed.x, transformed.y, transformed.z); new_min = new_min.min(transformed); new_max = new_max.max(transformed); } @@ -578,16 +577,16 @@ impl Camera { /// current target: `x`/`y` span the screen plane, `z` runs along the eye /// direction. The offset is taken in f64 before the cast, so a corner at /// UTM scale doesn't lose the difference to cancellation. - fn bounds_in_view(&self, min: Vec3, max: Vec3) -> [Vec3; 8] { + fn bounds_in_view(&self, min: DVec3, max: DVec3) -> [Vec3; 8] { let inv = self.rotation.inverse(); let mut out = [Vec3::ZERO; 8]; for (i, slot) in out.iter_mut().enumerate() { - let corner = Vec3::new( + let corner = DVec3::new( if i & 1 == 0 { min.x } else { max.x }, if i & 2 == 0 { min.y } else { max.y }, if i & 4 == 0 { min.z } else { max.z }, ); - *slot = inv * (corner.as_dvec3() - self.target).as_vec3(); + *slot = inv * (corner - self.target).as_vec3(); } out } @@ -595,7 +594,7 @@ impl Camera { /// Half-extent of `min..max` along the current eye direction (with the /// same 5% margin `ortho_depth_range` needs), measured from the target. /// This is exactly what `distance ± r` has to contain to avoid clipping. - fn depth_extent_in_view(&self, min: Vec3, max: Vec3) -> f32 { + fn depth_extent_in_view(&self, min: DVec3, max: DVec3) -> f32 { let depth_r = self .bounds_in_view(min, max) .iter() @@ -613,7 +612,11 @@ impl Camera { /// 800 km below its plane zoomed out to 800 km and became a dot. The two /// agree on a flat drawing, which is why it went unnoticed. pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3, aspect: f32) { - self.target = ((min + max) * 0.5).as_dvec3(); + self.fit_to_bounds_f64(min.as_dvec3(), max.as_dvec3(), aspect); + } + + pub fn fit_to_bounds_f64(&mut self, min: DVec3, max: DVec3, aspect: f32) { + self.target = (min + max) * 0.5; let corners = self.bounds_in_view(min, max); // Half-height the view needs so the box fits BOTH axes at the given // viewport aspect. The old circumscribed-radius rule fitted the box's @@ -628,7 +631,7 @@ impl Camera { // that to get the distance which just contains `half_h`, plus a small // border margin. self.distance = (half_h / (self.fov_y * 0.5).tan() * 1.1).max(1e-3); - self.fit_depth_to_bounds(min, max); + self.fit_depth_to_bounds_f64(min, max); } /// Size only the near/far span to `min..max`, leaving the pose alone. @@ -636,7 +639,7 @@ impl Camera { /// Split out of [`fit_to_bounds`] for the camera restored from a file's /// saved view: that pose must not move, but its depth range still has to /// cover the model or geometry outside it is silently clipped away. - pub fn fit_depth_to_bounds(&mut self, min: Vec3, max: Vec3) { + pub fn fit_depth_to_bounds_f64(&mut self, min: DVec3, max: DVec3) { // Cache the box so `ortho_depth_range` re-derives the near/far depth for // the live eye direction every frame — orbiting off the fitted pose then // never clips the drawing (#473). Keeping it tied to the model (not to @@ -648,7 +651,7 @@ impl Camera { self.depth_half_range = self.depth_extent_in_view(min, max); } - pub(crate) fn fitted_model_bounds(&self) -> Option<(Vec3, Vec3)> { + pub(crate) fn fitted_model_bounds(&self) -> Option<(DVec3, DVec3)> { self.model_bounds } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index cb96d6ea9..29a9f25cc 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3174,6 +3174,7 @@ impl Scene { model_render_mode: acadrust::entities::ViewportRenderMode, _hover_region: Option, show_viewcube: bool, + show_interaction: bool, viewcube_text_color: [f32; 4], ) -> Primitive { let nav_build_started = iced::time::Instant::now(); @@ -3181,7 +3182,7 @@ impl Scene { // Hover comes from the scene cell driven by the app-level // `CursorMoved` handler — the cube overlay sits above the shader // and would otherwise mask the move event from `Program::update`. - let hover_region = self.viewcube_hover.get(); + let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten(); self.selection.borrow_mut().vp_size = (bounds.width, bounds.height); if bounds.height > 0.0 { self.set_render_aspect(bounds.width / bounds.height); @@ -3197,7 +3198,14 @@ impl Scene { .iter() .filter_map(|inst| { let force = self.refresh_consume(self.instance_id_for(inst)); - self.viewport_data_for(inst, canvas, hover_region, show_viewcube, force) + self.viewport_data_for( + inst, + canvas, + hover_region, + show_viewcube, + show_interaction, + force, + ) }) .collect(); // Empty viewports → blit nothing; the container background (model bg @@ -3226,9 +3234,10 @@ impl Scene { tile_idx: usize, model_render_mode: acadrust::entities::ViewportRenderMode, show_viewcube: bool, + show_interaction: bool, viewcube_text_color: [f32; 4], ) -> Primitive { - let hover_region = self.viewcube_hover.get(); + let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten(); let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); let bg_color = [0.0, 0.0, 0.0, 0.0]; let tiles = self.model_tiles.borrow(); @@ -3279,7 +3288,14 @@ impl Scene { }; let force = self.refresh_consume(self.instance_id_for(&inst)); let viewports = self - .viewport_data_for(&inst, canvas, hover_region, show_viewcube, force) + .viewport_data_for( + &inst, + canvas, + hover_region, + show_viewcube, + show_interaction, + force, + ) .into_iter() .collect(); let perf_nav = perf_nav.map(|mut sample| { @@ -3304,6 +3320,7 @@ impl Scene { canvas: (f32, f32), hover_region: Option, show_viewcube: bool, + show_interaction: bool, force_rasterize: bool, ) -> Option { let display = self.viewport_display_settings(inst); @@ -3443,7 +3460,7 @@ impl Scene { // paper layout, model-space overlays go to all content viewports while // paper-space overlays stay on the sheet. This also keeps model-space // coordinates out of the full-canvas sheet pass (#540). - let show_live_overlay = if self.current_layout == "Model" { + let show_live_overlay = show_interaction && if self.current_layout == "Model" { true } else if self.active_viewport.is_some() { !inst.paper_sheet @@ -3592,11 +3609,12 @@ impl Scene { // paper area. Content viewport model builders are block-filtered too; // the scissor only clips their already-correct Model Space set. let (hatches, wipeout_hatches, paper_images) = if inst.paper_sheet { - let (hatches, wipeouts, images) = self.paper_sheet_render_models(); + let (hatches, wipeouts, images) = + self.paper_sheet_render_models_for_view(show_interaction); (hatches, wipeouts, Some(images)) } else { ( - self.hatch_models_for_viewport(inst.handle, &vp_frozen), + self.hatch_models_for_viewport(inst.handle, &vp_frozen, show_interaction), self.wipeout_models_for_viewport(inst.handle, &vp_frozen), None, ) @@ -3750,10 +3768,21 @@ impl Scene { camera_generation: self.camera_generation, wire_content_id, wire_patch, - selected_handles: Arc::new(self.selected.iter().copied().collect()), - hover_handles: Arc::new(self.hover_highlight_handles()), - selection_generation: self.selection_generation, - selected_sig: self.selected_set_sig(), + selected_handles: Arc::new(if show_interaction { + self.selected.iter().copied().collect() + } else { + rustc_hash::FxHashSet::default() + }), + hover_handles: Arc::new(if show_interaction { + self.hover_highlight_handles() + } else { + rustc_hash::FxHashSet::default() + }), + selection_generation: self + .selection_generation + .wrapping_mul(2) + .wrapping_add(u64::from(!show_interaction)), + selected_sig: if show_interaction { self.selected_set_sig() } else { 0 }, screen_rect, }) } diff --git a/src/scene/view/viewport_pane.rs b/src/scene/view/viewport_pane.rs index a87a8959c..55aa42e84 100644 --- a/src/scene/view/viewport_pane.rs +++ b/src/scene/view/viewport_pane.rs @@ -16,6 +16,7 @@ use iced::{mouse, Event, Rectangle}; pub struct ViewportPane<'a> { pub scene: &'a Scene, pub show_viewcube: bool, + pub show_interaction: bool, /// Render mode applied to the active Model tile or active paper viewport. /// The app may supply a temporary gallery-hover mode; inactive viewports /// keep the values stored on their own tile/entity. @@ -33,12 +34,14 @@ impl<'a> ViewportPane<'a> { pub fn model( scene: &'a Scene, show_viewcube: bool, + show_interaction: bool, render_mode: acadrust::entities::ViewportRenderMode, viewcube_text_color: [f32; 4], ) -> Self { Self { scene, show_viewcube, + show_interaction, render_mode, pane: None, viewcube_text_color, @@ -50,6 +53,7 @@ impl<'a> ViewportPane<'a> { pub fn for_pane( scene: &'a Scene, show_viewcube: bool, + show_interaction: bool, render_mode: acadrust::entities::ViewportRenderMode, tile_idx: usize, viewcube_text_color: [f32; 4], @@ -57,6 +61,7 @@ impl<'a> ViewportPane<'a> { Self { scene, show_viewcube, + show_interaction, render_mode, pane: Some(tile_idx), viewcube_text_color, @@ -82,6 +87,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> idx, self.render_mode, self.show_viewcube, + self.show_interaction, self.viewcube_text_color, ), None => self.scene.build_viewports( @@ -89,6 +95,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> self.render_mode, state.hover_region, self.show_viewcube, + self.show_interaction, self.viewcube_text_color, ), }