diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index fc1709aab..0b72f0e8d 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -1508,6 +1508,41 @@ impl ViewCubePipeline { } } + /// Check whether the ViewCube can be cleanly displayed inside `surface_clip`. + /// + /// The ViewCube anchors to the top-right corner of the viewport. It should + /// render whenever: + /// 1. Its top-right corner is on-canvas (not scrolled or clipped off the top + /// or right of the canvas / parent clip). + /// 2. The visible surface area (`surface_clip`) has sufficient width and + /// height to display the cube without distorted scaling. + pub fn should_render( + &self, + surface_dest: Rectangle, + surface_clip: Rectangle, + clip: &Rectangle, + ) -> bool { + let min_size = self.depth_texture_size.width.min(self.depth_texture_size.height).max(1); + viewcube_should_render(surface_dest, surface_clip, clip, min_size) + } +} + +/// Check whether the ViewCube can be cleanly displayed inside `surface_clip`. +pub fn viewcube_should_render( + surface_dest: Rectangle, + surface_clip: Rectangle, + clip: &Rectangle, + min_size: u32, +) -> bool { + let clip_right = clip.x + clip.width; + // Allow 1px subpixel tolerance between float ceil/floor and integer clip rounding. + let top_right_visible = surface_dest.y >= clip.y.saturating_sub(1) + && surface_dest.x + surface_dest.width <= clip_right + 1; + top_right_visible && surface_clip.width >= min_size && surface_clip.height >= min_size +} + +impl ViewCubePipeline { + pub fn render( &self, encoder: &mut wgpu::CommandEncoder, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index a1f79be28..c1f00f1cd 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1326,10 +1326,6 @@ retained_contributors={}", if surface_clip.width == 0 || surface_clip.height == 0 { continue; } - let vp_full_x = surface_dest.x as i32; - let vp_full_y = surface_dest.y as i32; - let vp_full_w = surface_dest.width as i32; - let vp_full_h = surface_dest.height as i32; // `mesh_fill` is false for Wireframe 2D / Wireframe 3D — flip // the draw path so meshes use the wireframe pipeline + the // pre-built triangle-edge index buffer. @@ -1345,26 +1341,11 @@ retained_contributors={}", vp.hidden_line, vp.show_3d_edges, ); - // The ViewCube renders directly to the surface at the full - // viewport rect. Skip it when the viewport's top-right corner - // (where the cube sits) is off-canvas — wgpu's `set_viewport` - // rejects negative origins, and a clamped cube would scale - // distortedly. The active viewport is normally fully visible. - if vp.show_viewcube - && surface_dest.width == placement.size.width - && surface_dest.height == placement.size.height - && vp_full_x >= clip.x as i32 - && vp_full_y >= clip.y as i32 - && vp_full_x + vp_full_w <= clip_right as i32 - && vp_full_y + vp_full_h <= clip_bottom as i32 - { - let vp_clip = Rectangle { - x: vp_full_x as u32, - y: vp_full_y as u32, - width: vp_full_w as u32, - height: vp_full_h as u32, - }; - inner.viewcube.render(encoder, target, vp_clip); + // The ViewCube renders directly to the surface in the top-right corner + // of the viewport. Skip it only when the top-right corner is off-canvas + // or the visible area cannot fit the cube. + if vp.show_viewcube && inner.viewcube.should_render(surface_dest, surface_clip, clip) { + inner.viewcube.render(encoder, target, surface_clip); } } let render_ms = nav_render_started.elapsed().as_secs_f64() * 1000.0; @@ -1634,6 +1615,108 @@ mod pixel_placement_tests { assert_eq!(moved.surface.x, first.surface.x + 20); } + #[test] + fn viewcube_visibility_across_display_scales_and_bounds() { + use crate::scene::pipeline::viewcube::viewcube_should_render; + + // Model space viewports across standard UI display scales and fractional layouts + for scale in [1.0, 1.25, 1.5, 1.75, 2.0] { + let window = Size::new((1920.0 * scale) as u32, (1080.0 * scale) as u32); + let bounds = Rectangle { + x: 0.0, + y: 154.5, + width: 1920.0, + height: 800.3, + }; + let rect = Rectangle { + x: bounds.x * scale, + y: bounds.y * scale, + width: bounds.width * scale, + height: bounds.height * scale, + }; + let placement = physical_viewport(rect, window); + let clip = Rectangle { + x: (bounds.x * scale).round() as u32, + y: (bounds.y * scale).round() as u32, + width: (bounds.width * scale).round() as u32, + height: (bounds.height * scale).round() as u32, + }; + let clip_right = clip.x + clip.width; + let clip_bottom = clip.y + clip.height; + let left = placement.surface.x.max(clip.x); + let top = placement.surface.y.max(clip.y); + let surface_clip = Rectangle { + x: left, + y: top, + width: (placement.surface.x + placement.surface.width) + .min(clip_right) + .saturating_sub(left), + height: (placement.surface.y + placement.surface.height) + .min(clip_bottom) + .saturating_sub(top), + }; + let viewcube_side = (crate::scene::VIEWCUBE_RENDER_PX.ceil() * scale).ceil() as u32; + assert!( + viewcube_should_render(placement.surface, surface_clip, &clip, viewcube_side), + "ViewCube must be visible in model space at scale {scale}" + ); + } + + // Paper space viewport scrolled off the top edge: should not render + let clip: Rectangle = Rectangle { + x: 0, + y: 100, + width: 1000, + height: 800, + }; + let scrolled_off_top: Rectangle = Rectangle { + x: 50, + y: 50, + width: 400, + height: 300, + }; // y is above clip.y (100) + let left = scrolled_off_top.x.max(clip.x); + let top = scrolled_off_top.y.max(clip.y); + let surface_clip = Rectangle { + x: left, + y: top, + width: (scrolled_off_top.x + scrolled_off_top.width) + .min(clip.x + clip.width) + .saturating_sub(left), + height: (scrolled_off_top.y + scrolled_off_top.height) + .min(clip.y + clip.height) + .saturating_sub(top), + }; + assert!( + !viewcube_should_render(scrolled_off_top, surface_clip, &clip, 120), + "ViewCube must hide when top-right corner is off top of canvas" + ); + + // Paper space viewport scrolled off the right edge: should not render + let scrolled_off_right: Rectangle = Rectangle { + x: 800, + y: 150, + width: 400, + height: 300, + }; // x + w (1200) exceeds clip right (1000) + let left = scrolled_off_right.x.max(clip.x); + let top = scrolled_off_right.y.max(clip.y); + let surface_clip = Rectangle { + x: left, + y: top, + width: (scrolled_off_right.x + scrolled_off_right.width) + .min(clip.x + clip.width) + .saturating_sub(left), + height: (scrolled_off_right.y + scrolled_off_right.height) + .min(clip.y + clip.height) + .saturating_sub(top), + }; + assert!( + !viewcube_should_render(scrolled_off_right, surface_clip, &clip, 120), + "ViewCube must hide when top-right corner is off right of canvas" + ); + } + #[test] #[ignore = "requires a GPU adapter"] fn fractional_viewports_keep_wire_pixels_aligned_after_blitting() {