feat(viewport): hide ViewCube then render bar as the viewport narrows

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-10 14:58:48 +03:00
commit 7cba5c8679
5 changed files with 128 additions and 27 deletions

View file

@ -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<std::sync::atomic::AtomicU32>,
/// 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,

View file

@ -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.

View file

@ -846,6 +846,7 @@ impl Scene {
bounds: Rectangle,
model_render_mode: acadrust::entities::ViewportRenderMode,
_hover_region: Option<usize>,
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<ViewportData> = 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<usize>,
show_viewcube: bool,
) -> Option<ViewportData> {
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,

View file

@ -69,12 +69,18 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> 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,
),
}
}

View file

@ -670,6 +670,11 @@ pub struct DensitySwap<'a> {
variants: Vec<Element<'a, Message>>,
chosen: Cell<usize>,
height_out: Option<Arc<AtomicU32>>,
/// 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<Arc<AtomicU32>>,
}
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<AtomicU32>) -> Self {
self.width0_out = Some(out);
self
}
}
impl<'a> Widget<Message, Theme, Renderer> for DensitySwap<'a> {
@ -718,6 +730,11 @@ impl<'a> Widget<Message, Theme, Renderer> 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;