feat(viewport): reorder model controls, add close-vport button

Reorder the model-tile control chip to grid, grid snap, render mode,
vertical split, horizontal split. Add a close button (shown only with
more than one model tile) that merges the active pane into its
longest-contact neighbour.

Divider drags now clamp each side to the minimum tile size, so dragging
a boundary to the screen edge no longer collapses a pane — closing is
done with the button instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-26 21:15:00 +03:00
commit 2baabdf4a0
4 changed files with 148 additions and 136 deletions

View file

@ -1078,6 +1078,9 @@ pub enum Message {
/// Split the active Model viewport in two. `true` → horizontal divider /// Split the active Model viewport in two. `true` → horizontal divider
/// (top / bottom); `false` → vertical divider (left / right). /// (top / bottom); `false` → vertical divider (left / right).
SplitModelViewport(bool), SplitModelViewport(bool),
/// Close the active Model viewport, merging it into a neighbour.
/// Only meaningful when more than one model tile exists.
CloseModelViewport,
/// Recall previous command in history (↑ arrow key). /// Recall previous command in history (↑ arrow key).
CommandHistoryPrev, CommandHistoryPrev,
/// Recall next command in history (↓ arrow key). /// Recall next command in history (↓ arrow key).

View file

@ -53,10 +53,9 @@ const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;
/// Pixel distance from a Model-tile inner divider that still registers as /// Pixel distance from a Model-tile inner divider that still registers as
/// a resize grip on the press. /// a resize grip on the press.
const TILE_EDGE_HIT_PX: f32 = 4.0; const TILE_EDGE_HIT_PX: f32 = 4.0;
/// Normalized minimum tile size before `collapse_small_model_tiles` merges /// Normalized minimum tile size a divider drag clamps to. Sized to
/// it into a neighbour. Sized to comfortably contain the ViewCube + its /// comfortably contain the ViewCube + its padding so every tile always
/// padding so a tile that's still GPU-rendered always has room to show /// has room to show the gizmo.
/// the gizmo.
fn tile_min_norm(canvas_w: f32, canvas_h: f32) -> (f32, f32) { fn tile_min_norm(canvas_w: f32, canvas_h: f32) -> (f32, f32) {
let px = VIEWCUBE_DRAW_PX + 2.0 * VIEWCUBE_PAD + 16.0; let px = VIEWCUBE_DRAW_PX + 2.0 * VIEWCUBE_PAD + 16.0;
( (
@ -1562,6 +1561,13 @@ impl OpenCADStudio {
Task::none() Task::none()
} }
Message::CloseModelViewport => {
let i = self.active_tab;
self.tabs[i].scene.close_active_model_tile();
self.tabs[i].scene.camera_generation += 1;
Task::none()
}
Message::CommandHistoryPrev => { Message::CommandHistoryPrev => {
// Grip popup wins first — arrow keys walk its items. // Grip popup wins first — arrow keys walk its items.
if let Some(popup) = self.grip_popup.as_mut() { if let Some(popup) = self.grip_popup.as_mut() {
@ -3472,15 +3478,10 @@ impl OpenCADStudio {
return Task::none(); return Task::none();
} }
// End an in-flight tile-divider drag. Any tile that fell // End an in-flight tile-divider drag. The move clamps
// below the minimum (viewcube fits comfortably) gets // each side to its minimum, so panes never collapse from
// absorbed into its longest-contact neighbour, so the // dragging — use the close button to remove a pane.
// user can drag a divider all the way to one side to
// remove a pane.
if self.tile_drag.take().is_some() { if self.tile_drag.take().is_some() {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (min_w, min_h) = tile_min_norm(vw, vh);
self.tabs[i].scene.collapse_small_model_tiles(min_w, min_h);
self.tabs[i].scene.camera_generation += 1; self.tabs[i].scene.camera_generation += 1;
return Task::none(); return Task::none();
} }

View file

@ -436,6 +436,7 @@ impl OpenCADStudio {
self.show_grid, self.show_grid,
self.snapper.grid_snap(), self.snapper.grid_snap(),
true, true,
tab.scene.model_tiles.borrow().len(),
); );
// Position the bar at the active model tile's top-left corner so // 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 // it follows the active panel in a tiled layout (full canvas when
@ -520,6 +521,7 @@ impl OpenCADStudio {
self.show_grid, self.show_grid,
self.snapper.grid_snap(), self.snapper.grid_snap(),
false, false,
0,
)), )),
], ],
] ]
@ -4504,6 +4506,7 @@ fn viewport_controls<'a>(
show_grid: bool, show_grid: bool,
snap_on: bool, snap_on: bool,
include_split: bool, include_split: bool,
tile_count: usize,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
use acadrust::entities::ViewportRenderMode as M; use acadrust::entities::ViewportRenderMode as M;
let render_modes: Vec<RenderModeChoice> = vec![ let render_modes: Vec<RenderModeChoice> = vec![
@ -4586,19 +4589,25 @@ fn viewport_controls<'a>(
let mut bar = row![] let mut bar = row![]
.spacing(3) .spacing(3)
.align_y(iced::alignment::Vertical::Center); .align_y(iced::alignment::Vertical::Center);
if include_split {
bar = bar
.push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true)))
.push(sep())
.push(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false)))
.push(sep());
}
bar = bar bar = bar
.push(picker)
.push(sep())
.push(icon_btn(crate::ui::icons::GRID, show_grid, Message::ToggleGrid)) .push(icon_btn(crate::ui::icons::GRID, show_grid, Message::ToggleGrid))
.push(sep()) .push(sep())
.push(icon_btn(crate::ui::icons::SNAP, snap_on, Message::ToggleGridSnap)); .push(icon_btn(crate::ui::icons::SNAP, snap_on, Message::ToggleGridSnap))
.push(sep())
.push(picker);
if include_split {
bar = bar
.push(sep())
.push(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false)))
.push(sep())
.push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true)));
// Close button: only meaningful with more than one model tile.
if tile_count > 1 {
bar = bar
.push(sep())
.push(icon_btn(crate::ui::icons::CLOSE, false, Message::CloseModelViewport));
}
}
container(bar) container(bar)
.padding(2) .padding(2)

View file

@ -7172,9 +7172,9 @@ impl Scene {
/// Move the inner divider edge from `old_coord` to `new_coord`, both /// Move the inner divider edge from `old_coord` to `new_coord`, both
/// in normalized 0..1 space. Adjusts every tile that touches the /// in normalized 0..1 space. Adjusts every tile that touches the
/// edge on either side. `min_size` clamps the new coordinate so no /// edge on either side. `min_size` clamps the new coordinate so no
/// tile crosses into a non-positive width / height — caller still /// tile on either side can shrink below the minimum — dragging a
/// runs `collapse_small_model_tiles` afterward to merge any tiles /// divider to the screen edge stops at that minimum instead of
/// that fell below the viewcube threshold. /// closing the pane (use the close button for that).
pub fn move_model_tile_edge( pub fn move_model_tile_edge(
&self, &self,
orient: TileEdgeOrient, orient: TileEdgeOrient,
@ -7192,26 +7192,26 @@ impl Scene {
let mut hi = 1.0_f32; let mut hi = 1.0_f32;
for t in tiles.iter() { for t in tiles.iter() {
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS { if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
lo = lo.max(t.rect.x + min_size * 0.25); lo = lo.max(t.rect.x + min_size);
} }
if (t.rect.x - old_coord).abs() < TILE_EPS { if (t.rect.x - old_coord).abs() < TILE_EPS {
hi = hi.min(t.rect.x + t.rect.width - min_size * 0.25); hi = hi.min(t.rect.x + t.rect.width - min_size);
} }
} }
new_coord.clamp(lo, hi) new_coord.clamp(lo, hi.max(lo))
} }
TileEdgeOrient::Horizontal => { TileEdgeOrient::Horizontal => {
let mut lo = 0.0_f32; let mut lo = 0.0_f32;
let mut hi = 1.0_f32; let mut hi = 1.0_f32;
for t in tiles.iter() { for t in tiles.iter() {
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS { if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
lo = lo.max(t.rect.y + min_size * 0.25); lo = lo.max(t.rect.y + min_size);
} }
if (t.rect.y - old_coord).abs() < TILE_EPS { if (t.rect.y - old_coord).abs() < TILE_EPS {
hi = hi.min(t.rect.y + t.rect.height - min_size * 0.25); hi = hi.min(t.rect.y + t.rect.height - min_size);
} }
} }
new_coord.clamp(lo, hi) new_coord.clamp(lo, hi.max(lo))
} }
}; };
for t in tiles.iter_mut() { for t in tiles.iter_mut() {
@ -7238,124 +7238,123 @@ impl Scene {
} }
} }
/// Remove every tile whose width or height has dropped below the /// Close the active Model tile, absorbing its area into the
/// supplied minima, absorbing each one's area into the neighbour /// neighbour that shares the longest contact edge and rebinding the
/// that shares the longest contact edge. Iterates until no tile is /// live camera to that neighbour. No-op with fewer than two tiles.
/// too small (handles chains of collapses). Adjusts pub fn close_active_model_tile(&self) {
/// `active_model_tile` so the live camera stays bound to a real
/// tile (preferring the neighbour that absorbed the active tile).
pub fn collapse_small_model_tiles(&self, min_w: f32, min_h: f32) {
let mut tiles = self.model_tiles.borrow_mut(); let mut tiles = self.model_tiles.borrow_mut();
loop { if tiles.len() < 2 {
if tiles.len() < 2 { return;
break; }
let idx = self.active_model_tile.get().min(tiles.len() - 1);
self.absorb_model_tile(&mut tiles, idx);
}
/// Drop tile `idx`, growing the neighbour with the longest shared
/// contact edge to cover the vacated area. Fixes up
/// `active_model_tile` so the live camera stays bound to a real tile
/// (preferring the neighbour that absorbed it). Falls back to
/// stretching the first remaining tile to fill the canvas if the
/// tile has no axis-aligned neighbour.
fn absorb_model_tile(&self, tiles: &mut Vec<ModelTile>, idx: usize) {
let removed = tiles[idx].rect;
// Find the neighbour with the longest shared contact edge.
let mut best: Option<(usize, f32, ContactSide)> = None;
for (j, t) in tiles.iter().enumerate() {
if j == idx {
continue;
} }
let small = tiles let probes = [
.iter() (
.enumerate() ContactSide::Left,
.find(|(_, t)| t.rect.width < min_w || t.rect.height < min_h) ((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS,
.map(|(i, _)| i); overlap_len(
let Some(idx) = small else { break }; (t.rect.y, t.rect.y + t.rect.height),
let removed = tiles[idx].rect; (removed.y, removed.y + removed.height),
// Find the neighbour with the longest shared contact edge.
let mut best: Option<(usize, f32, ContactSide)> = None;
for (j, t) in tiles.iter().enumerate() {
if j == idx {
continue;
}
let probes = [
(
ContactSide::Left,
((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS,
overlap_len(
(t.rect.y, t.rect.y + t.rect.height),
(removed.y, removed.y + removed.height),
),
), ),
( ),
ContactSide::Right, (
(t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS, ContactSide::Right,
overlap_len( (t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS,
(t.rect.y, t.rect.y + t.rect.height), overlap_len(
(removed.y, removed.y + removed.height), (t.rect.y, t.rect.y + t.rect.height),
), (removed.y, removed.y + removed.height),
), ),
( ),
ContactSide::Top, (
((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS, ContactSide::Top,
overlap_len( ((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS,
(t.rect.x, t.rect.x + t.rect.width), overlap_len(
(removed.x, removed.x + removed.width), (t.rect.x, t.rect.x + t.rect.width),
), (removed.x, removed.x + removed.width),
), ),
( ),
ContactSide::Bottom, (
(t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS, ContactSide::Bottom,
overlap_len( (t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS,
(t.rect.x, t.rect.x + t.rect.width), overlap_len(
(removed.x, removed.x + removed.width), (t.rect.x, t.rect.x + t.rect.width),
), (removed.x, removed.x + removed.width),
), ),
]; ),
for (side, touches, c) in probes { ];
if touches && c > 0.0 { for (side, touches, c) in probes {
if best.map_or(true, |(_, len, _)| c > len) { if touches && c > 0.0 {
best = Some((j, c, side)); if best.map_or(true, |(_, len, _)| c > len) {
} best = Some((j, c, side));
} }
} }
} }
if let Some((nbr_idx, _, side)) = best { }
match side { if let Some((nbr_idx, _, side)) = best {
ContactSide::Left => { match side {
tiles[nbr_idx].rect.width = ContactSide::Left => {
(removed.x + removed.width) - tiles[nbr_idx].rect.x; tiles[nbr_idx].rect.width =
} (removed.x + removed.width) - tiles[nbr_idx].rect.x;
ContactSide::Right => {
let old_right =
tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width;
tiles[nbr_idx].rect.x = removed.x;
tiles[nbr_idx].rect.width = old_right - removed.x;
}
ContactSide::Top => {
tiles[nbr_idx].rect.height =
(removed.y + removed.height) - tiles[nbr_idx].rect.y;
}
ContactSide::Bottom => {
let old_bottom =
tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height;
tiles[nbr_idx].rect.y = removed.y;
tiles[nbr_idx].rect.height = old_bottom - removed.y;
}
} }
let active = self.active_model_tile.get(); ContactSide::Right => {
let new_active = if active == idx { let old_right =
if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx } tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width;
} else if active > idx { tiles[nbr_idx].rect.x = removed.x;
active - 1 tiles[nbr_idx].rect.width = old_right - removed.x;
} else { }
active ContactSide::Top => {
}; tiles[nbr_idx].rect.height =
tiles.remove(idx); (removed.y + removed.height) - tiles[nbr_idx].rect.y;
self.active_model_tile }
.set(new_active.min(tiles.len().saturating_sub(1))); ContactSide::Bottom => {
let old_bottom =
tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height;
tiles[nbr_idx].rect.y = removed.y;
tiles[nbr_idx].rect.height = old_bottom - removed.y;
}
}
let active = self.active_model_tile.get();
let new_active = if active == idx {
if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx }
} else if active > idx {
active - 1
} else { } else {
// Isolated tile (shouldn't happen with axis-aligned active
// splits) — drop it and stretch the first remaining };
// tile to fill the canvas so we don't leave a hole. tiles.remove(idx);
tiles.remove(idx); self.active_model_tile
let active = self.active_model_tile.get(); .set(new_active.min(tiles.len().saturating_sub(1)));
self.active_model_tile } else {
.set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1))); // Isolated tile (shouldn't happen with axis-aligned
if let Some(first) = tiles.first_mut() { // splits) — drop it and stretch the first remaining
first.rect = iced::Rectangle { // tile to fill the canvas so we don't leave a hole.
x: 0.0, tiles.remove(idx);
y: 0.0, let active = self.active_model_tile.get();
width: 1.0, self.active_model_tile
height: 1.0, .set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1)));
}; if let Some(first) = tiles.first_mut() {
} first.rect = iced::Rectangle {
break; x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
};
} }
} }
} }