From 00cc17ea558159490356b1eedf261e00e1defa74 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 28 Jul 2026 18:51:20 +0300 Subject: [PATCH] fix(scene): isolate drawing-space state Cancel stale interaction state across space changes and keep live previews and zoom extents in their active paper/model context.\n\nRefs #539\nRefs #540\nRefs #541 --- src/app/command_driver.rs | 138 +++++++++++++++++++++++++++++- src/app/update/command.rs | 43 +--------- src/app/update/mod.rs | 70 ++++++++++++--- src/app/update/viewport.rs | 54 ++++++++++-- src/command.rs | 12 +++ src/modules/draw/draw/polyline.rs | 7 ++ src/scene/camera_ops.rs | 104 +++++++++++++++++++++- src/scene/limits.rs | 32 +------ src/scene/mspace.rs | 40 +++++++++ src/scene/view/render.rs | 20 ++++- 10 files changed, 424 insertions(+), 96 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 57db4801c..b5792778a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4,6 +4,133 @@ use acadrust::Handle; use iced::Task; impl OpenCADStudio { + /// Drop cursor-relative state that was computed in the drawing space being + /// left. This is also used by MVIEW, whose command object survives its + /// intentional paper/model round-trip while its old-space overlays cannot. + pub(super) fn reset_space_interaction_state(&mut self) { + let i = self.active_tab; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].snap_result = None; + self.last_point = None; + self.snapper.from_point = None; + self.snapper.clear_tracking(); + self.otrack_active = None; + self.axis_lock_dir = None; + self.dyn_user_reshaped = false; + self.grip_hover = None; + self.grip_popup = None; + self.grip_pending = None; + self.visibility_popup = None; + self.hover_dwell = None; + self.ucs_grip_drag = None; + self.ucs_icon_selected = false; + self.ucs_icon_hover = false; + self.tabs[i].pan_mode = false; + let _ = self.on_viewport_exit(); + } + + /// Roll a hot grip back to its pre-drag image and remove every grip-owned + /// overlay. Shared by Escape and drawing-space transitions. + pub(super) fn cancel_active_grip_edit(&mut self) -> bool { + let i = self.active_tab; + let had_grip = self.tabs[i].active_grip.take().is_some() + || self.grip_add_provisional.is_some() + || self.grip_preview_handle.is_some(); + if !had_grip { + return false; + } + + // An Add-Leader arrow being placed is still provisional. + if let Some((handle, grip_id)) = self.grip_add_provisional.take() { + use crate::entities::traits::EntityTypeOps; + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + entity.apply_grip_menu( + grip_id, + crate::scene::model::object::GripMenuAction::RemoveLeader, + ); + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } + + if let Some(handle) = self.grip_preview_handle.take() { + if let Some(original) = self.grip_original.take() { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + *entity = original; + } + } + self.tabs[i].scene.preview_hidden.remove(&handle); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } else { + self.grip_original = None; + } + + self.grip_text_verts.clear(); + self.grip_text_slide = false; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].snap_result = None; + self.refresh_selected_grips(); + self.refresh_properties(); + true + } + + /// End an interactive command before its drawing coordinate context + /// changes. This is a full interaction boundary, not only an `active_cmd` + /// check: grip drags, suspended editor commands, snaps, tracking, dynamic + /// input and pointer gestures all carry coordinates from the old space. + pub(super) fn cancel_active_command_for_space_change(&mut self) -> Task { + let i = self.active_tab; + let mut tasks = Vec::new(); + let mut cancellation_reported = false; + + if let Some(result) = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.on_space_change()) + { + let (result, message_pending) = match result { + CmdResult::Cancel => (CmdResult::CancelForSpaceChange, false), + other => (other, true), + }; + tasks.push(self.apply_cmd_result(result)); + + // `on_space_change` must be terminal. Force a plain cancellation + // if an external/plugin command violates that contract. + if self.tabs[i].active_cmd.is_some() { + tasks.push(self.apply_cmd_result(CmdResult::CancelForSpaceChange)); + } else if message_pending { + self.command_line + .push_info("Command cancelled because the active drawing space changed."); + } + cancellation_reported = true; + } + + let grip_cancelled = self.cancel_active_grip_edit(); + let suspended_cancelled = self.tabs[i].suspended_cmd.take().is_some(); + let editor_cancelled = self.text_inline.is_some() || self.mtext_editor.is_some(); + if editor_cancelled { + self.text_inline_cancel(); + self.mtext_cancel(); + } + if !cancellation_reported && (grip_cancelled || suspended_cancelled || editor_cancelled) { + self.command_line + .push_info("Command cancelled because the active drawing space changed."); + } + + self.command_line.input.clear(); + self.command_line.autocomplete_cursor = None; + self.command_line.close_history(); + self.reset_space_interaction_state(); + if tasks.is_empty() { + Task::none() + } else { + Task::batch(tasks) + } + } + /// Apply LIMCHECK/PLIMCHECK to a point before an interactive command /// consumes it. LIMITS itself must be able to redefine a rectangle beyond /// the old boundary, so it is the sole bypass. @@ -611,7 +738,7 @@ impl OpenCADStudio { } } CmdResult::MviewSwitchLayout(layout) => { - let task = self.on_layout_switch(layout); + let task = self.on_layout_switch_preserving_command(layout); if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { @@ -1149,12 +1276,17 @@ impl OpenCADStudio { self.command_line.push_info(&p); } } - CmdResult::Cancel => { + cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { + let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_info("Command cancelled."); + self.command_line.push_info(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }); } CmdResult::Relaunch(cmd, handles) => { self.tabs[i].scene.deselect_all(); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a5d446383..f2052961f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -524,46 +524,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.input.clear(); return Task::none(); } - // A hot grip (click-move-click placement in progress) ends on - // Escape, leaving the entity at its last previewed position. - if self.tabs[self.active_tab].active_grip.take().is_some() { - // An Add-Leader arrow being placed: Esc removes it again. - if let Some((h, gid)) = self.grip_add_provisional.take() { - let i = self.active_tab; - use crate::entities::traits::EntityTypeOps; - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) { - e.apply_grip_menu( - gid, - crate::scene::model::object::GripMenuAction::RemoveLeader, - ); - } - self.tabs[i] - .scene - .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); - self.refresh_selected_grips(); - } - // Cancel an in-progress grip drag: restore the edited - // entity from its pre-drag backup, un-hide it, re-tessellate - // once, and drop the preview. - if let Some(h) = self.grip_preview_handle.take() { - let i = self.active_tab; - self.grip_text_verts = Vec::new(); - self.grip_text_slide = false; - if let Some(orig) = self.grip_original.take() { - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) { - *e = orig; - } - } - self.tabs[i].scene.preview_hidden.remove(&h); - self.tabs[i].scene.clear_preview_wire(); - // Geometry restored to the backup — re-tessellate just it. - self.tabs[i] - .scene - .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); - self.refresh_selected_grips(); - } - self.tabs[self.active_tab].snap_result = None; - self.refresh_properties(); + // A hot grip (click-move-click placement in progress) rolls + // back to its pre-drag image on Escape. + if self.cancel_active_grip_edit() { return Task::none(); } // Cancel layout rename first, then fall through. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b13eec8a5..c13a2ae3b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3393,14 +3393,25 @@ impl OpenCADStudio { Message::LayoutDelete(name) => { let i = self.active_tab; + let deleting_current = self.tabs[i].scene.current_layout == name; + let cancel_task = if deleting_current { + self.cancel_active_command_for_space_change() + } else { + Task::none() + }; self.push_undo_snapshot(i, "LAYOUT DEL"); + let switch_task = if deleting_current { + self.on_layout_switch("Model".to_string()) + } else { + Task::none() + }; if self.tabs[i].scene.delete_layout(&name) { self.layout_rename_state = None; self.command_line .push_output(&format!("Layout \"{name}\" silindi")); self.tabs[i].dirty = true; } - Task::none() + Task::batch([cancel_task, switch_task]) } Message::LayoutRenameStart(name) => { @@ -3520,20 +3531,28 @@ impl OpenCADStudio { if name == "Model" { self.command_line .push_error("Cannot delete the Model layout."); + Task::none() } else { + let deleting_current = self.tabs[i].scene.current_layout == name; + let cancel_task = if deleting_current { + self.cancel_active_command_for_space_change() + } else { + Task::none() + }; self.push_undo_snapshot(i, "LAYOUT DELETE"); + let switch_task = if deleting_current { + self.on_layout_switch("Model".to_string()) + } else { + Task::none() + }; self.tabs[i].scene.delete_layout(&name); self.tabs[i].dirty = true; - // Switch to Model if active layout was deleted. - if self.tabs[i].scene.current_layout == name { - self.tabs[i].scene.set_current_layout("Model".to_string()); - } self.layout_manager_selected = "Model".to_string(); self.layout_manager_rename_buf = String::new(); self.command_line .push_output(&format!("Layout '{name}' deleted.")); + Task::batch([cancel_task, switch_task]) } - Task::none() } Message::LayoutManagerMoveLeft => { let i = self.active_tab; @@ -3571,12 +3590,13 @@ impl OpenCADStudio { Task::none() } Message::LayoutManagerSetCurrent => { - let i = self.active_tab; let name = self.layout_manager_selected.clone(); - self.tabs[i].scene.set_current_layout(name.clone()); - self.command_line - .push_output(&format!("Switched to layout '{name}'.")); - Task::none() + let task = self.on_layout_switch(name.clone()); + if self.tabs[self.active_tab].scene.current_layout == name { + self.command_line + .push_output(&format!("Switched to layout '{name}'.")); + } + task } Message::SetTheme(theme) => { @@ -4084,8 +4104,17 @@ impl OpenCADStudio { Message::EnterViewport(handle) => { let i = self.active_tab; + let context_changed = self.tabs[i].scene.active_viewport != Some(handle); + let cancel_task = if context_changed { + self.cancel_active_command_for_space_change() + } else { + Task::none() + }; let perf = crate::perf::enabled(); let total = Instant::now(); + if context_changed { + self.tabs[i].scene.clear_preview_wire(); + } // Clear paper-space selection before entering model space. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = Some(handle); @@ -4119,11 +4148,23 @@ impl OpenCADStudio { handle.value(), ); } - Task::none() + if context_changed { + self.sync_dyn_fields(); + } + cancel_task } Message::ExitViewport => { let i = self.active_tab; + let context_changed = self.tabs[i].scene.active_viewport.is_some(); + let cancel_task = if context_changed { + self.cancel_active_command_for_space_change() + } else { + Task::none() + }; + if context_changed { + self.tabs[i].scene.clear_preview_wire(); + } // Clear model-space selection before returning to paper space. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = None; @@ -4133,7 +4174,10 @@ impl OpenCADStudio { self.tabs[i].refresh_active_ucs(); self.refresh_properties(); self.command_line.push_output("PSPACE"); - Task::none() + if context_changed { + self.sync_dyn_fields(); + } + cancel_task } Message::MspaceCommand => { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 8980cbbe3..1433a6296 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1488,7 +1488,7 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_viewport_exit(&mut self) -> Task { + pub(crate) fn on_viewport_exit(&mut self) -> Task { let i = self.active_tab; let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.left_down = false; @@ -3676,6 +3676,24 @@ impl OpenCADStudio { } pub(crate) fn on_layout_switch(&mut self, name: String) -> Task { + self.on_layout_switch_inner(name, false) + } + + /// MVIEW's "Insert view > New" flow deliberately visits Model space to + /// define a view and then returns to its paper layout. This is the only + /// layout transition allowed to preserve an active command. + pub(crate) fn on_layout_switch_preserving_command( + &mut self, + name: String, + ) -> Task { + self.on_layout_switch_inner(name, true) + } + + fn on_layout_switch_inner( + &mut self, + name: String, + preserve_active_command: bool, + ) -> Task { let i = self.active_tab; if self.tabs[i].is_start { self.command_line @@ -3693,6 +3711,25 @@ impl OpenCADStudio { let perf = crate::perf::enabled(); let perf_total = Instant::now(); let perf_from = self.tabs[i].scene.current_layout.clone(); + let context_changed = + self.tabs[i].scene.current_layout != name || self.tabs[i].scene.active_viewport.is_some(); + let preserve_active_command = preserve_active_command + && self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "MVIEW"); + if context_changed { + if preserve_active_command { + // MVIEW keeps its command-owned step data, but all host-owned + // cursor/snap/dynamic-input state belongs to the old space. + self.reset_space_interaction_state(); + } + } + let cancel_task = if context_changed && !preserve_active_command { + self.cancel_active_command_for_space_change() + } else { + Task::none() + }; let going_to_paper = name != "Model"; // Persist the camera of the layout we're leaving BEFORE switching // so returning to it restores where the user left off (the @@ -3715,6 +3752,9 @@ impl OpenCADStudio { self.tabs[i].refresh_active_ucs(); self.tabs[i].scene.restore_saved_camera(); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; + // `deselect_all` invalidates the scene highlight, but grips and the + // Properties panel are separate caches owned by the app. + self.refresh_properties(); // Grid/snap are per-view: load the layout we just entered (its // sheet viewport in paper space, the model tile in model space) // so model and each layout keep independent grid state. @@ -3744,7 +3784,10 @@ impl OpenCADStudio { self.tabs[i].scene.geometry_epoch, ); } - Task::none() + if context_changed { + self.sync_dyn_fields(); + } + cancel_task } pub(super) fn on_layout_create(&mut self) -> Task { @@ -3754,6 +3797,7 @@ impl OpenCADStudio { .push_info("Open or create a drawing to add a layout."); return Task::none(); } + let cancel_task = self.cancel_active_command_for_space_change(); // Find a unique name (e.g. Layout2, Layout3, ...). let existing = self.tabs[i].scene.layout_names(); let mut idx = existing.len(); @@ -3779,22 +3823,22 @@ impl OpenCADStudio { } } } - self.tabs[i].scene.set_current_layout(new_name.clone()); // Safety net — `add_layout` already creates the overall // sheet viewport; this covers any path that doesn't. self.tabs[i].scene.ensure_sheet_viewport(&new_name); - self.tabs[i].scene.deselect_all(); + let switch_task = self.on_layout_switch(new_name.clone()); self.tabs[i].scene.fit_all(); self.command_line.push_output(&format!( "Layout \"{new_name}\" created — use MVIEW to add a viewport" )); self.tabs[i].dirty = true; + return Task::batch([cancel_task, switch_task]); } Err(e) => self .command_line .push_error(&format!("Failed to create layout: {e}")), } - Task::none() + cancel_task } pub(super) fn on_layout_rename_commit(&mut self) -> Task { diff --git a/src/command.rs b/src/command.rs index 3c069fdd5..c6f0382e4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -848,6 +848,9 @@ pub enum CmdResult { ReplaceMany(Vec<(Handle, Vec)>, Vec), /// Cancel: discard any preview and end the command. Cancel, + /// Cancel because the active drawing space changed. Cleanup is identical + /// to `Cancel`, but the host reports the context change explicitly. + CancelForSpaceChange, /// End the selection-gather phase and re-dispatch the named command /// with the gathered handles installed as the active scene selection. Relaunch(String, Vec), @@ -1279,6 +1282,15 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Abort because the active drawing coordinate space is about to change. + /// Unlike `on_escape`, the default never interprets cancellation as + /// "finish with the points collected so far" (SPLINE does that for a + /// deliberate Escape). Commands owning a live document entity can + /// override this to close that entity's deferred history safely. + fn on_space_change(&mut self) -> CmdResult { + CmdResult::Cancel + } + /// Returns `true` when the command needs entity picking (hit-test) instead of point picking. fn needs_entity_pick(&self) -> bool { false diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index 4b4311af1..16d366bec 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -362,6 +362,13 @@ impl CadCommand for PlineCommand { } } + fn on_space_change(&mut self) -> CmdResult { + match self.live_handle { + Some(handle) => CmdResult::FinalizeLiveEntity(handle), + None => CmdResult::Cancel, + } + } + fn wants_text_input(&self) -> bool { // Accept A / L / C once we have at least the first point. !self.vertices.is_empty() diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 6948e1d30..8070065b5 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -655,7 +655,108 @@ impl Scene { self.camera.borrow_mut().fit_depth_to_bounds(min, max); } + fn fit_paper_space_extents(&mut self) { + let layout_block = self.current_layout_block_handle(); + let mut min = glam::Vec3::splat(f32::INFINITY); + let mut max = glam::Vec3::splat(f32::NEG_INFINITY); + { + let mut include = |x: f64, y: f64, z: f64| { + const SANE_EXTENT: f64 = 1.0e16; + if x.is_finite() + && y.is_finite() + && z.is_finite() + && x.abs() < SANE_EXTENT + && y.abs() < SANE_EXTENT + && z.abs() < SANE_EXTENT + { + let point = glam::Vec3::new(x as f32, y as f32, z as f32); + min = min.min(point); + max = max.max(point); + } + }; + + // The physical sheet is always part of Paper Space extents, even + // when the layout contains no entities (#539). + if let Some(((x0, y0), (x1, y1))) = self.paper_limits() { + include(x0, y0, 0.0); + include(x1, y1, 0.0); + } + + // Paper entities and viewport borders belong to the sheet. Model + // content projected through those viewports deliberately does not. + for wire in self.wires_for_block_culled(layout_block, None, None, None, None) { + let is_infinite = Self::handle_from_wire_name(&wire.name) + .and_then(|handle| self.document.get_entity(handle)) + .is_some_and(|entity| { + matches!(entity, EntityType::XLine(_) | EntityType::Ray(_)) + }); + if is_infinite { + for &[x, y, z] in &wire.key_vertices { + include(x, y, z); + } + } else { + for (index, &[x, y, z]) in wire.points.iter().enumerate() { + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + include( + x as f64 + low[0] as f64, + y as f64 + low[1] as f64, + z as f64 + low[2] as f64, + ); + } + } + } + + // Hatches, wipeouts and raster images are GPU-only and may carry + // no normal wire outline, so include their visible paper bounds. + let (hatches, wipeouts, images) = self.paper_sheet_render_models(); + for hatch in hatches.iter().chain(wipeouts.iter()) { + for &[x, y] in hatch.boundary.iter() { + include( + hatch.world_origin[0] + x as f64, + hatch.world_origin[1] + y as f64, + 0.0, + ); + } + } + for image in images.iter() { + for (corner, low) in image.corners.iter().zip(image.corners_low.iter()) { + include( + corner[0] as f64 + low[0] as f64, + corner[1] as f64 + low[1] as f64, + corner[2] as f64 + low[2] as f64, + ); + } + } + } + + if !min.is_finite() || !max.is_finite() { + return; + } + if min == max { + max += glam::Vec3::splat(1.0); + } + self.camera + .borrow_mut() + .fit_to_bounds(min, max, self.last_render_aspect.get().max(0.01)); + self.camera_generation += 1; + } + pub fn fit_all(&mut self) { + if self.active_viewport.is_some() { + if let Some((mut min, mut max)) = self.model_space_extents() { + if min == max { + max += glam::Vec3::splat(1.0); + min -= glam::Vec3::splat(1.0); + } + self.fit_active_viewport_to_bounds(min, max); + } + return; + } + if self.current_layout != "Model" { + self.fit_paper_space_extents(); + return; + } + // Use the FULL, un-culled wire set — not `entity_wires()`, which is // frustum-culled to the current view. Culled input would fit only the // entities already on screen, so each call would zoom out a little and @@ -664,9 +765,6 @@ impl Scene { // the bounds don't drift with zoom-adaptive curve sampling. let layout_block = self.current_layout_block_handle(); let mut wires = self.wires_for_block_culled(layout_block, None, None, None, None); - if self.current_layout != "Model" { - wires.extend(self.viewport_content_wires(layout_block, None, None)); - } // Ray / XLine tessellate as ±DISPLAY_EXTENT display segments // (entities/ray.rs) — their endpoints are rendering artifacts, not // drawing extent. A construction line through the drawing defeats diff --git a/src/scene/limits.rs b/src/scene/limits.rs index b6004997f..e470a9057 100644 --- a/src/scene/limits.rs +++ b/src/scene/limits.rs @@ -132,36 +132,8 @@ impl Scene { let max = glam::Vec3::new(limit_max.x as f32, limit_max.y as f32, 0.0); // MSPACE owns a camera encoded on the active viewport entity. - if let Some(viewport_handle) = self.active_viewport { - let (width, height, locked) = match self.document.get_entity(viewport_handle) { - Some(EntityType::Viewport(viewport)) => { - (viewport.width, viewport.height, viewport.status.locked) - } - _ => return, - }; - if locked { - return; - } - let aspect = (width / height.max(1e-9)) as f32; - let mut camera = match self.viewport_edit_frame(self.selection.borrow().vp_size) { - Some((camera, _)) => camera, - None => return, - }; - camera.fit_to_bounds(min, max, aspect.max(0.01)); - if let Some(EntityType::Viewport(viewport)) = - self.document.get_entity_mut(viewport_handle) - { - viewport.view_target.x = camera.target.x; - viewport.view_target.y = camera.target.y; - viewport.view_target.z = camera.target.z; - viewport.view_center.x = 0.0; - viewport.view_center.y = 0.0; - viewport.view_height = camera.ortho_size() as f64 * 2.0; - if viewport.view_height > 1e-9 { - viewport.custom_scale = viewport.height / viewport.view_height; - } - } - self.camera_generation += 1; + if self.active_viewport.is_some() { + self.fit_active_viewport_to_bounds(min, max); return; } diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index aa4bd5b6c..1b6d210e6 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -111,6 +111,46 @@ impl Scene { } } + /// Fit model-space bounds into the active floating viewport without moving + /// the surrounding paper-space camera. + pub fn fit_active_viewport_to_bounds( + &mut self, + min: glam::Vec3, + max: glam::Vec3, + ) -> bool { + let Some(viewport_handle) = self.active_viewport else { + return false; + }; + let (width, height, locked) = match self.document.get_entity(viewport_handle) { + Some(acadrust::EntityType::Viewport(viewport)) => { + (viewport.width, viewport.height, viewport.status.locked) + } + _ => return false, + }; + if locked { + return false; + } + let Some(mut camera) = self.camera_for_viewport(viewport_handle) else { + return false; + }; + camera.fit_to_bounds(min, max, (width / height.max(1e-9)) as f32); + if let Some(acadrust::EntityType::Viewport(viewport)) = + self.document.get_entity_mut(viewport_handle) + { + viewport.view_target.x = camera.target.x; + viewport.view_target.y = camera.target.y; + viewport.view_target.z = camera.target.z; + viewport.view_center.x = 0.0; + viewport.view_center.y = 0.0; + viewport.view_height = camera.ortho_size() as f64 * 2.0; + if viewport.view_height > 1e-9 { + viewport.custom_scale = viewport.height / viewport.view_height; + } + } + self.camera_generation += 1; + true + } + /// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels. /// The delta is converted to model-space units using the camera and viewport scale. /// No-op when there is no active viewport. diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 8c4489837..cda55df62 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1991,7 +1991,21 @@ impl Scene { // per-frame buffer so the (potentially huge) base buffer stays resident // and unchanged while a command preview or grip drag is live. let all_wires = other_arc; - let preview_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() { + // A live overlay belongs to exactly one drawing context. In a paper + // layout, feeding model-space preview coordinates to the full-canvas + // sheet pass draws a second copy outside the floating viewport (#540). + // The inverse is equally wrong: paper-space coordinates must not be + // interpreted by a content viewport's model camera. + let show_live_overlay = if self.current_layout == "Model" { + inst.active + } else if let Some(active_viewport) = self.active_viewport { + !inst.paper_sheet && inst.handle == active_viewport + } else { + inst.paper_sheet + }; + let preview_wires = if !show_live_overlay + || (self.interim_wire.is_none() && self.preview_wires.is_empty()) + { Arc::new(Vec::new()) } else { let mut v: Vec = Vec::with_capacity(self.preview_wires.len() + 1); @@ -2150,7 +2164,9 @@ impl Scene { pv.extend_from_slice(&w.text_verts); } } - pv.extend_from_slice(&self.preview_text); + if show_live_overlay { + pv.extend_from_slice(&self.preview_text); + } Arc::new(pv) }; // Stable per-viewport identity (tagged so tile / sheet / content /