From 7cba5c86791ee5b7642cca40794237cf0d0bbcf0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 10 Jul 2026 14:58:48 +0300 Subject: [PATCH] feat(viewport): hide ViewCube then render bar as the viewport narrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the viewport got too narrow the top-left render-mode bar and the top-right ViewCube overlapped. Hide them adaptively, measuring the render bar's real width rather than guessing: - Wrap the render bar in DensitySwap, which measures its natural width every frame and swaps it for an empty spacer once it no longer fits the tile. Add report_width0 so it publishes that measured width to render_bar_w. - Compute viewcube_visible from that measured width: the cube shows only while bar_w + gap + cube region fits. This one flag drives the ViewCube nav/UCS widgets, the hover hit-test, and — threaded through build_viewports / build_viewport_for_pane / viewport_data_for — the GPU cube itself, which previously ignored the pane flag (show_viewcube was inst.active only), so the cube stayed while its surrounding widgets vanished. Result: the ViewCube gives way first, then the render bar once it too has no room — no overlap, sized to the bar's real width (which varies with the render-mode label, split buttons and tile count). Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 6 ++ src/app/view/mod.rs | 102 ++++++++++++++++++++++++++------ src/scene/view/render.rs | 12 +++- src/scene/view/viewport_pane.rs | 18 ++++-- src/ui/wrap_bar.rs | 17 ++++++ 5 files changed, 128 insertions(+), 27 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 614194709..bf17d5032 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -398,6 +398,11 @@ pub(super) struct OpenCADStudio { ucs_icon_at_origin: bool, /// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE). show_viewcube: bool, + /// Measured natural width (px, as `f32` bits) of the active viewport's + /// render-mode control bar, written each frame by its `DensitySwap` and read + /// next frame to decide whether the ViewCube still has room beside it — so + /// the two corner widgets adapt to the bar's real width, not an estimate. + render_bar_w: std::sync::Arc, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, /// Whether the document file tabs are shown at the top (FILETAB). @@ -2145,6 +2150,7 @@ impl OpenCADStudio { show_ucs_icon: true, ucs_icon_at_origin: true, show_viewcube: true, + render_bar_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), show_properties: true, show_file_tabs: true, show_layout_tabs: true, diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6416f8986..5337e59b5 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -6,6 +6,7 @@ use super::{Message, OpenCADStudio}; use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; +use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ button, canvas, column, container, mouse_area, pane_grid, responsive, row, shader, stack, text, @@ -32,6 +33,18 @@ pub(in crate::app) use overlay::{MTEXT_TEXT_ID, TEXT_INLINE_ID}; const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; +/// Clear gap (px) kept between the render-mode bar (top-left) and the ViewCube +/// (top-right) before the cube is judged to collide and hides. +const VIEWCUBE_GAP: f32 = 12.0; + +/// True when a viewport `tile_w` px wide still has room for the ViewCube beside +/// a render-mode bar of measured width `bar_w`. When it doesn't, the cube hides +/// first (the bar keeps priority); the bar itself hides separately, only when it +/// no longer fits at all (its `DensitySwap`). +fn viewcube_has_room(bar_w: f32, tile_w: f32) -> bool { + bar_w + VIEWCUBE_GAP + VIEWCUBE_REGION_PX + VIEWCUBE_PAD <= tile_w +} + /// `ViewportRenderMode` enum carries the raw DXF integers, not a label, /// so wrap it locally with a friendly name renderer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -69,6 +82,30 @@ impl OpenCADStudio { let i = self.active_tab; let tab = &self.tabs[i]; let is_paper = tab.scene.current_layout != "Model"; + // Adaptive corner widgets: the ViewCube shows only while the active + // viewport is wide enough to hold it *beside* the render-mode bar, whose + // real width is measured each frame by its `DensitySwap` and read back + // here (from the previous frame). This drives the GPU cube (via the pane + // flag), the ViewCube nav/UCS widgets, and the hover hit-test alike, so + // they never overlap. The bar itself hides independently (its own + // DensitySwap) only when it no longer fits at all. + let render_bar_w = + f32::from_bits(self.render_bar_w.load(std::sync::atomic::Ordering::Relaxed)); + let active_vp_w = if is_paper { + tab.scene + .active_viewport + .and_then(|h| { + let (cw, ch) = tab.scene.selection.borrow().vp_size; + tab.scene.viewport_screen_rect(h, (cw, ch)) + }) + .map(|r| r.width) + .unwrap_or(f32::INFINITY) + } else { + 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); // 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 @@ -89,7 +126,7 @@ impl OpenCADStudio { } else if is_paper { shader(ViewportPane::model( &tab.scene, - self.show_viewcube, + viewcube_visible, tab.render_mode, )) .width(Fill) @@ -106,7 +143,7 @@ impl OpenCADStudio { // current (building the pane_grid inside `responsive` resets the // mouse_areas' hover state and drops their move events). let scene = &tab.scene; - let show_viewcube = self.show_viewcube; + let show_viewcube = viewcube_visible; let render_mode = tab.render_mode; let size_probe: Element<'_, Message> = responsive(move |size| { { @@ -468,7 +505,8 @@ impl OpenCADStudio { ost_points, otrack_line, parallel_ref_marker, - !is_paper && self.show_viewcube, + // ViewCube hover region matches the drawn cube — gone when hidden. + !is_paper && viewcube_visible, dividers, pane_move_rect, pane_drop_rect, @@ -657,6 +695,8 @@ impl OpenCADStudio { // viewport drawing / selection is unaffected. In a paper layout // the active viewport gets its own picker (below) instead. if !is_paper && !tab.is_start { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let rect = tab.scene.active_model_tile_bounds(vw, vh); // Unified control chip: split buttons + render-mode picker + // grid / grid-snap toggles, for the active Model tile. let bar = viewport_controls( @@ -666,16 +706,28 @@ impl OpenCADStudio { true, tab.scene.model_tiles.borrow().len(), ); - // Position the bar at the active model tile's top-left corner so - // it follows the active panel in a tiled layout (full canvas when - // a single tile fills the window). Leading Spaces offset it. - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let rect = tab.scene.active_model_tile_bounds(vw, vh); + // Adaptive: DensitySwap measures the bar's real width every frame + // (reported into `render_bar_w`, which the ViewCube reads to decide + // overlap) and swaps it for an empty spacer only when it no longer + // fits the tile. The fixed-width container bounds that fit decision + // to the tile, not the whole canvas. + let adaptive: Element<'_, Message> = DensitySwap::new(vec![ + iced::widget::opaque(bar), + Space::new() + .width(iced::Length::Fixed(0.0)) + .height(iced::Length::Fixed(0.0)) + .into(), + ]) + .report_width0(self.render_bar_w.clone()) + .into(); + // Position the bar at the active model tile's top-left corner so it + // follows the active panel in a tiled layout (full canvas when a + // single tile fills the window). Leading Spaces offset it. let bar_layer = column![ Space::new().height(iced::Length::Fixed(rect.y.max(0.0))), row![ Space::new().width(iced::Length::Fixed(rect.x.max(0.0))), - iced::widget::opaque(bar), + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), ], ] .width(Fill) @@ -740,24 +792,38 @@ impl OpenCADStudio { .scene .active_viewport_render_mode() .unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D); + // Adaptive (same as model): the picker measures its real width into + // `render_bar_w` and swaps to an empty spacer only when the viewport + // can't hold it; the ViewCube reads that width to decide overlap. + let bar = viewport_controls( + vp_mode, + self.show_grid, + self.snapper.grid_snap(), + false, + 0, + ); + let adaptive: Element<'_, Message> = DensitySwap::new(vec![ + iced::widget::opaque(bar), + Space::new() + .width(iced::Length::Fixed(0.0)) + .height(iced::Length::Fixed(0.0)) + .into(), + ]) + .report_width0(self.render_bar_w.clone()) + .into(); let picker_layer = column![ Space::new().height(iced::Length::Fixed(y + 4.0)), row![ Space::new().width(iced::Length::Fixed(x + 4.0)), - iced::widget::opaque(viewport_controls( - vp_mode, - self.show_grid, - self.snapper.grid_snap(), - false, - 0, - )), + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), ], ] .width(Fill) .height(Fill); viewport_stack = viewport_stack.push(picker_layer); - if self.show_viewcube { + // Hide the ViewCube first — before the render bar — when they collide. + if viewcube_visible { let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); @@ -799,7 +865,7 @@ impl OpenCADStudio { } } - if self.show_viewcube && !is_paper && !tab.is_start { + if viewcube_visible && !is_paper { // Place the ViewCube hit area in the active model tile's top-right // corner so it tracks the active panel in a tiled layout. The hit // test in update.rs already maps clicks through the active tile. diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 5c51d4618..77c37756d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -846,6 +846,7 @@ impl Scene { bounds: Rectangle, model_render_mode: acadrust::entities::ViewportRenderMode, _hover_region: Option, + show_viewcube: bool, ) -> Primitive { // Hover comes from the scene cell driven by the app-level // `CursorMoved` handler — the cube overlay sits above the shader @@ -864,7 +865,7 @@ impl Scene { let bg_color = [0.0, 0.0, 0.0, 0.0]; let viewports: Vec = instances .iter() - .filter_map(|inst| self.viewport_data_for(inst, canvas, hover_region)) + .filter_map(|inst| self.viewport_data_for(inst, canvas, hover_region, show_viewcube)) .collect(); // Empty viewports → blit nothing; the container background (model bg // or the paper desk colour) stays visible. @@ -886,6 +887,7 @@ impl Scene { bounds: Rectangle, tile_idx: usize, model_render_mode: acadrust::entities::ViewportRenderMode, + show_viewcube: bool, ) -> Primitive { let hover_region = self.viewcube_hover.get(); let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); @@ -926,7 +928,7 @@ impl Scene { paper_sheet: false, }; let viewports = self - .viewport_data_for(&inst, canvas, hover_region) + .viewport_data_for(&inst, canvas, hover_region, show_viewcube) .into_iter() .collect(); Primitive { @@ -945,6 +947,7 @@ impl Scene { inst: &ViewportInstance, canvas: (f32, f32), hover_region: Option, + show_viewcube: bool, ) -> Option { let flags = render_mode_flags(inst.render_mode); let view_wireframe = !flags.face3d_fill; @@ -1159,7 +1162,10 @@ impl Scene { compass_rotation: inst.camera.view_rotation_mat(), // Only the active viewport gets the hovered-region highlight. hover_region: if inst.active { hover_region } else { None }, - show_viewcube: inst.active, + // The cube shows only on the active viewport, and only while the + // caller (the widget) says there is room for it beside the render + // bar — so it hides adaptively when the viewport gets narrow. + show_viewcube: inst.active && show_viewcube, fill_mode: self.document.header.fill_mode, view_wireframe, mesh_fill: flags.mesh_fill, diff --git a/src/scene/view/viewport_pane.rs b/src/scene/view/viewport_pane.rs index a50967022..a3cf1a044 100644 --- a/src/scene/view/viewport_pane.rs +++ b/src/scene/view/viewport_pane.rs @@ -69,12 +69,18 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> bounds: Rectangle, ) -> Self::Primitive { match self.pane { - Some(idx) => self - .scene - .build_viewport_for_pane(bounds, idx, self.render_mode), - None => self - .scene - .build_viewports(bounds, self.render_mode, state.hover_region), + Some(idx) => self.scene.build_viewport_for_pane( + bounds, + idx, + self.render_mode, + self.show_viewcube, + ), + None => self.scene.build_viewports( + bounds, + self.render_mode, + state.hover_region, + self.show_viewcube, + ), } } diff --git a/src/ui/wrap_bar.rs b/src/ui/wrap_bar.rs index 58446ffda..a2aedb8c9 100644 --- a/src/ui/wrap_bar.rs +++ b/src/ui/wrap_bar.rs @@ -670,6 +670,11 @@ pub struct DensitySwap<'a> { variants: Vec>, chosen: Cell, height_out: Option>, + /// Receives the FIRST (widest) variant's natural width in bits of an `f32`, + /// measured every layout regardless of which variant is shown — so a caller + /// can place a neighbouring widget relative to the full-size content even + /// while a narrower variant is on screen. + width0_out: Option>, } impl<'a> DensitySwap<'a> { @@ -678,6 +683,7 @@ impl<'a> DensitySwap<'a> { variants, chosen: Cell::new(0), height_out: None, + width0_out: None, } } @@ -687,6 +693,12 @@ impl<'a> DensitySwap<'a> { self.height_out = Some(out); self } + + /// Report the first variant's natural (unconstrained) width — see `width0_out`. + pub fn report_width0(mut self, out: Arc) -> Self { + self.width0_out = Some(out); + self + } } impl<'a> Widget for DensitySwap<'a> { @@ -718,6 +730,11 @@ impl<'a> Widget for DensitySwap<'a> { let mut pick = self.variants.len().saturating_sub(1); for (i, v) in self.variants.iter_mut().enumerate() { let n = v.as_widget_mut().layout(&mut tree.children[i], renderer, &natural); + if i == 0 { + if let Some(out) = &self.width0_out { + out.store(n.size().width.to_bits(), Ordering::Relaxed); + } + } if n.size().width <= max_w { pick = i; break;