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:
parent
9c03ae2b4d
commit
2baabdf4a0
4 changed files with 148 additions and 136 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
231
src/scene/mod.rs
231
src/scene/mod.rs
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue