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

+27 -24
View File
@@ -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
}
+40 -11
View File
@@ -3174,6 +3174,7 @@ impl Scene {
model_render_mode: acadrust::entities::ViewportRenderMode,
_hover_region: Option<usize>,
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<usize>,
show_viewcube: bool,
show_interaction: bool,
force_rasterize: bool,
) -> Option<ViewportData> {
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,
})
}
+7
View File
@@ -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<Msg> 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<Msg> for ViewportPane<'a>
self.render_mode,
state.hover_region,
self.show_viewcube,
self.show_interaction,
self.viewcube_text_color,
),
}