forked from 1b-agent/opencadstudio-upstream
feat(viewport): draggable Model-tile dividers
Tiled Model layouts now show the inner edges between panes as 2-px
dividers that act as resize grips: cursor on a divider → resize cue,
press + drag moves the edge (with every tile sharing that edge
updating together), release runs a collapse pass that absorbs any
tile that fell below the viewcube-comfort minimum into the neighbour
with the longest shared contact edge — drag a divider all the way
across to remove a pane.
- Scene grows `model_tile_edges`, `hit_model_tile_edge`,
`move_model_tile_edge`, `collapse_small_model_tiles`. Edges are
derived from the existing `model_tiles` rects (no extra storage).
- App carries a `tile_drag` Option; `ViewportLeftPress` starts it
ahead of tile activation / picking, `ViewportMove` drives the
edge, `ViewportLeftRelease` collapses + clears.
- `SelectionCanvas` draws the dividers and switches the cursor to
`Resizing{Horizontally, Vertically}` over them.
This commit is contained in:
1 parent
e3c962bdd1
commit
39eab9a4c4
5 files changed
+550
-2
No files matched your search
+17
-1
@@ -11,7 +11,18 @@ mod view;
|
||||
use document::DocumentTab;
|
||||
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::CubeRegion;
|
||||
use crate::scene::{CubeRegion, TileEdgeOrient};
|
||||
|
||||
/// In-flight drag of a Model-tile inner divider. `last_applied` is the
|
||||
/// most recent normalized coordinate handed to `move_model_tile_edge` —
|
||||
/// the next `ViewportMove` uses it as the `old_coord` argument so the
|
||||
/// drag follows the same edge across frames even after sub-pixel
|
||||
/// adjustments from the clamp inside `move_model_tile_edge`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TileDrag {
|
||||
pub orient: TileEdgeOrient,
|
||||
pub last_applied: f32,
|
||||
}
|
||||
use crate::snap::Snapper;
|
||||
use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar};
|
||||
use acadrust::types::{Color as AcadColor, LineWeight};
|
||||
@@ -73,6 +84,10 @@ pub(super) struct OpenCADStudio {
|
||||
/// `true` after a bare `VPORTS` in model space — the next command-line
|
||||
/// entry is treated as the tiled-config option (SIngle/2H/2V/4).
|
||||
awaiting_vports: bool,
|
||||
/// Active drag of a Model-tile divider. Set on press over an inner
|
||||
/// edge, updated on move, cleared on release (which also runs the
|
||||
/// collapse pass).
|
||||
tile_drag: Option<TileDrag>,
|
||||
/// Show the UCS icon in the bottom-left corner of model space (UCSICON).
|
||||
show_ucs_icon: bool,
|
||||
/// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE).
|
||||
@@ -747,6 +762,7 @@ impl OpenCADStudio {
|
||||
show_grid: false,
|
||||
dyn_input: true,
|
||||
awaiting_vports: false,
|
||||
tile_drag: None,
|
||||
show_ucs_icon: true,
|
||||
show_viewcube: true,
|
||||
show_properties: true,
|
||||
|
||||
+89
-1
@@ -6,7 +6,9 @@ use super::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::grip::{find_hit_grip, find_hit_grip_paper, GripEdit};
|
||||
use crate::scene::object::GripApply;
|
||||
use crate::scene::{self, hover_id, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX};
|
||||
use crate::scene::{
|
||||
self, hover_id, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::PropertiesPanel;
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||
@@ -15,6 +17,20 @@ use iced::window;
|
||||
use iced::{mouse, Task};
|
||||
|
||||
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;
|
||||
/// Pixel distance from a Model-tile inner divider that still registers as
|
||||
/// a resize grip on the press.
|
||||
const TILE_EDGE_HIT_PX: f32 = 4.0;
|
||||
/// Normalized minimum tile size before `collapse_small_model_tiles` merges
|
||||
/// it into a neighbour. Sized to comfortably contain the ViewCube + its
|
||||
/// padding so a tile that's still GPU-rendered always has room to show
|
||||
/// the gizmo.
|
||||
fn tile_min_norm(canvas_w: f32, canvas_h: f32) -> (f32, f32) {
|
||||
let px = VIEWCUBE_DRAW_PX + 2.0 * VIEWCUBE_PAD + 16.0;
|
||||
(
|
||||
(px / canvas_w.max(1.0)).min(0.95),
|
||||
(px / canvas_h.max(1.0)).min(0.95),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
@@ -1646,6 +1662,35 @@ impl OpenCADStudio {
|
||||
|
||||
Message::ViewportMove(p) => {
|
||||
let i = self.active_tab;
|
||||
|
||||
// A Model-tile divider drag short-circuits the rest of
|
||||
// the move handling — the cursor neither pans the camera
|
||||
// nor updates snap state while the user is resizing
|
||||
// panes.
|
||||
if let Some(drag) = self.tile_drag.as_mut() {
|
||||
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
|
||||
if vw > 1.0 && vh > 1.0 {
|
||||
let new_coord = match drag.orient {
|
||||
TileEdgeOrient::Vertical => (p.x / vw).clamp(0.0, 1.0),
|
||||
TileEdgeOrient::Horizontal => (p.y / vh).clamp(0.0, 1.0),
|
||||
};
|
||||
let (min_w, min_h) = tile_min_norm(vw, vh);
|
||||
let min_size = match drag.orient {
|
||||
TileEdgeOrient::Vertical => min_w,
|
||||
TileEdgeOrient::Horizontal => min_h,
|
||||
};
|
||||
self.tabs[i].scene.move_model_tile_edge(
|
||||
drag.orient,
|
||||
drag.last_applied,
|
||||
new_coord,
|
||||
min_size,
|
||||
);
|
||||
drag.last_applied = new_coord;
|
||||
self.tabs[i].scene.camera_generation += 1;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Keep the ViewCube hover in sync as the cursor leaves the
|
||||
// hit-area overlay and moves over the rest of the viewport.
|
||||
// `hover_id` returns None outside the cube box, which clears
|
||||
@@ -2084,6 +2129,33 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// Tiled Model layout: an inner divider near the cursor
|
||||
// becomes a resize grip. Start the drag and short-circuit
|
||||
// (no pick / select / camera swap). Released on
|
||||
// `ViewportLeftRelease`.
|
||||
if self.tabs[i].active_cmd.is_none()
|
||||
&& self.tabs[i].scene.current_layout == "Model"
|
||||
&& vw > 1.0
|
||||
&& vh > 1.0
|
||||
{
|
||||
let canvas_bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: vw,
|
||||
height: vh,
|
||||
};
|
||||
if let Some(edge) = self.tabs[i]
|
||||
.scene
|
||||
.hit_model_tile_edge(p, canvas_bounds, TILE_EDGE_HIT_PX)
|
||||
{
|
||||
self.tile_drag = Some(crate::app::TileDrag {
|
||||
orient: edge.orient,
|
||||
last_applied: edge.coord,
|
||||
});
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
|
||||
// Tiled Model layout: clicking a non-active tile activates
|
||||
// it (swapping in its camera) instead of selecting / drawing.
|
||||
if self.tabs[i].active_cmd.is_none() && vw > 1.0 && vh > 1.0 {
|
||||
@@ -2165,6 +2237,22 @@ impl OpenCADStudio {
|
||||
|
||||
Message::ViewportLeftRelease => {
|
||||
let i = self.active_tab;
|
||||
|
||||
// End an in-flight tile-divider drag. Any tile that fell
|
||||
// below the minimum (viewcube fits comfortably) gets
|
||||
// absorbed into its longest-contact neighbour, so the
|
||||
// user can drag a divider all the way to one side to
|
||||
// remove a pane.
|
||||
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;
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let (p, is_click, is_down) = {
|
||||
let sel = self.tabs[i].scene.selection.borrow();
|
||||
let p = match sel.last_move_pos {
|
||||
|
||||
@@ -371,6 +371,13 @@ impl OpenCADStudio {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Model-space tile dividers (none in paper / single-tile layouts).
|
||||
let tile_edges = if !is_paper {
|
||||
tab.scene.model_tile_edges()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
overlay::selection_overlay(
|
||||
sel,
|
||||
snap_info,
|
||||
@@ -380,6 +387,7 @@ impl OpenCADStudio {
|
||||
ost_points,
|
||||
tab.last_cursor_screen,
|
||||
!is_paper && self.show_viewcube,
|
||||
tile_edges,
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -439,6 +439,39 @@ pub(crate) struct ModelTile {
|
||||
pub(crate) camera: Camera,
|
||||
}
|
||||
|
||||
/// Tolerance for matching two normalized tile coordinates as "the same"
|
||||
/// edge — drag math leaves small floating-point residue.
|
||||
const TILE_EPS: f32 = 1e-4;
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum TileEdgeOrient {
|
||||
Vertical,
|
||||
Horizontal,
|
||||
}
|
||||
|
||||
/// One inner divider between Model tiles, exposed by [`Scene::model_tile_edges`].
|
||||
/// `coord` is the fixed axis (x for vertical, y for horizontal) and `span`
|
||||
/// is the perpendicular extent over which the divider actually separates
|
||||
/// tiles. All values are normalized to the 0..1 canvas.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TileEdge {
|
||||
pub orient: TileEdgeOrient,
|
||||
pub coord: f32,
|
||||
pub span: (f32, f32),
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum ContactSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||
(a.1.min(b.1) - a.0.max(b.0)).max(0.0)
|
||||
}
|
||||
|
||||
pub struct Scene {
|
||||
pub camera: Rc<RefCell<Camera>>,
|
||||
/// Model-space tiled viewport layout. One full-window tile by default;
|
||||
@@ -4463,6 +4496,326 @@ impl Scene {
|
||||
|
||||
// ── Paper-space coordinate helpers ───────────────────────────────────
|
||||
|
||||
/// Discover the inner divider edges between Model tiles. Each entry
|
||||
/// is one draggable horizontal or vertical edge, with the span along
|
||||
/// the perpendicular axis that the edge actually covers (the union
|
||||
/// of touching tiles' extents). Coordinates are in normalized 0..1
|
||||
/// canvas space. Returns an empty list outside Model or for a
|
||||
/// single-tile layout.
|
||||
pub fn model_tile_edges(&self) -> Vec<TileEdge> {
|
||||
if self.current_layout != "Model" {
|
||||
return vec![];
|
||||
}
|
||||
let tiles = self.model_tiles.borrow();
|
||||
if tiles.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// Collect candidate inner x's: any tile edge that's strictly
|
||||
// inside (0, 1). Dedup by epsilon.
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut ys: Vec<f32> = Vec::new();
|
||||
for t in tiles.iter() {
|
||||
for x in [t.rect.x, t.rect.x + t.rect.width] {
|
||||
if x > TILE_EPS && x < 1.0 - TILE_EPS {
|
||||
xs.push(x);
|
||||
}
|
||||
}
|
||||
for y in [t.rect.y, t.rect.y + t.rect.height] {
|
||||
if y > TILE_EPS && y < 1.0 - TILE_EPS {
|
||||
ys.push(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
xs.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||||
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS);
|
||||
for x in xs {
|
||||
let mut y0 = f32::INFINITY;
|
||||
let mut y1 = f32::NEG_INFINITY;
|
||||
let mut has_left = false;
|
||||
let mut has_right = false;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.x + t.rect.width) - x).abs() < TILE_EPS {
|
||||
has_left = true;
|
||||
y0 = y0.min(t.rect.y);
|
||||
y1 = y1.max(t.rect.y + t.rect.height);
|
||||
}
|
||||
if (t.rect.x - x).abs() < TILE_EPS {
|
||||
has_right = true;
|
||||
y0 = y0.min(t.rect.y);
|
||||
y1 = y1.max(t.rect.y + t.rect.height);
|
||||
}
|
||||
}
|
||||
if has_left && has_right && y1 > y0 {
|
||||
out.push(TileEdge {
|
||||
orient: TileEdgeOrient::Vertical,
|
||||
coord: x,
|
||||
span: (y0, y1),
|
||||
});
|
||||
}
|
||||
}
|
||||
for y in ys {
|
||||
let mut x0 = f32::INFINITY;
|
||||
let mut x1 = f32::NEG_INFINITY;
|
||||
let mut has_top = false;
|
||||
let mut has_bot = false;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.y + t.rect.height) - y).abs() < TILE_EPS {
|
||||
has_top = true;
|
||||
x0 = x0.min(t.rect.x);
|
||||
x1 = x1.max(t.rect.x + t.rect.width);
|
||||
}
|
||||
if (t.rect.y - y).abs() < TILE_EPS {
|
||||
has_bot = true;
|
||||
x0 = x0.min(t.rect.x);
|
||||
x1 = x1.max(t.rect.x + t.rect.width);
|
||||
}
|
||||
}
|
||||
if has_top && has_bot && x1 > x0 {
|
||||
out.push(TileEdge {
|
||||
orient: TileEdgeOrient::Horizontal,
|
||||
coord: y,
|
||||
span: (x0, x1),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hit-test the inner Model-tile dividers against a pixel cursor.
|
||||
/// `bounds` is the canvas pixel rectangle (origin = canvas top-left).
|
||||
/// Returns the closest edge within `tolerance_px` pixels of the cursor
|
||||
/// along its perpendicular axis, also requiring the cursor to lie
|
||||
/// within the edge's actual span.
|
||||
pub fn hit_model_tile_edge(
|
||||
&self,
|
||||
cursor_px: iced::Point,
|
||||
bounds: iced::Rectangle,
|
||||
tolerance_px: f32,
|
||||
) -> Option<TileEdge> {
|
||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let cx = cursor_px.x - bounds.x;
|
||||
let cy = cursor_px.y - bounds.y;
|
||||
let nx = cx / bounds.width;
|
||||
let ny = cy / bounds.height;
|
||||
let tol_nx = tolerance_px / bounds.width;
|
||||
let tol_ny = tolerance_px / bounds.height;
|
||||
let mut best: Option<(f32, TileEdge)> = None;
|
||||
for e in self.model_tile_edges() {
|
||||
let (dist, in_span) = match e.orient {
|
||||
TileEdgeOrient::Vertical => (
|
||||
(e.coord - nx).abs() / tol_nx.max(1e-9),
|
||||
ny >= e.span.0 && ny <= e.span.1,
|
||||
),
|
||||
TileEdgeOrient::Horizontal => (
|
||||
(e.coord - ny).abs() / tol_ny.max(1e-9),
|
||||
nx >= e.span.0 && nx <= e.span.1,
|
||||
),
|
||||
};
|
||||
if in_span && dist <= 1.0 {
|
||||
if best.as_ref().map_or(true, |(d, _)| dist < *d) {
|
||||
best = Some((dist, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, e)| e)
|
||||
}
|
||||
|
||||
/// Move the inner divider edge from `old_coord` to `new_coord`, both
|
||||
/// in normalized 0..1 space. Adjusts every tile that touches the
|
||||
/// edge on either side. `min_size` clamps the new coordinate so no
|
||||
/// tile crosses into a non-positive width / height — caller still
|
||||
/// runs `collapse_small_model_tiles` afterward to merge any tiles
|
||||
/// that fell below the viewcube threshold.
|
||||
pub fn move_model_tile_edge(
|
||||
&self,
|
||||
orient: TileEdgeOrient,
|
||||
old_coord: f32,
|
||||
new_coord: f32,
|
||||
min_size: f32,
|
||||
) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
// Clamp the new coordinate so no tile becomes ≤ 0 wide / tall.
|
||||
// (Sub-`min_size` results are still allowed — the collapse pass
|
||||
// handles those.)
|
||||
let new_coord = match orient {
|
||||
TileEdgeOrient::Vertical => {
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||||
lo = lo.max(t.rect.x + min_size * 0.25);
|
||||
}
|
||||
if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.x + t.rect.width - min_size * 0.25);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi)
|
||||
}
|
||||
TileEdgeOrient::Horizontal => {
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for t in tiles.iter() {
|
||||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||||
lo = lo.max(t.rect.y + min_size * 0.25);
|
||||
}
|
||||
if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.y + t.rect.height - min_size * 0.25);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi)
|
||||
}
|
||||
};
|
||||
for t in tiles.iter_mut() {
|
||||
match orient {
|
||||
TileEdgeOrient::Vertical => {
|
||||
if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS {
|
||||
t.rect.width = (new_coord - t.rect.x).max(0.0);
|
||||
} else if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
let old_right = t.rect.x + t.rect.width;
|
||||
t.rect.x = new_coord;
|
||||
t.rect.width = (old_right - new_coord).max(0.0);
|
||||
}
|
||||
}
|
||||
TileEdgeOrient::Horizontal => {
|
||||
if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS {
|
||||
t.rect.height = (new_coord - t.rect.y).max(0.0);
|
||||
} else if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
let old_bottom = t.rect.y + t.rect.height;
|
||||
t.rect.y = new_coord;
|
||||
t.rect.height = (old_bottom - new_coord).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove every tile whose width or height has dropped below the
|
||||
/// supplied minima, absorbing each one's area into the neighbour
|
||||
/// that shares the longest contact edge. Iterates until no tile is
|
||||
/// too small (handles chains of collapses). Adjusts
|
||||
/// `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();
|
||||
loop {
|
||||
if tiles.len() < 2 {
|
||||
break;
|
||||
}
|
||||
let small = tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, t)| t.rect.width < min_w || t.rect.height < min_h)
|
||||
.map(|(i, _)| i);
|
||||
let Some(idx) = small else { break };
|
||||
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 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,
|
||||
overlap_len(
|
||||
(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,
|
||||
overlap_len(
|
||||
(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,
|
||||
overlap_len(
|
||||
(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 {
|
||||
if best.map_or(true, |(_, len, _)| c > len) {
|
||||
best = Some((j, c, side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((nbr_idx, _, side)) = best {
|
||||
match side {
|
||||
ContactSide::Left => {
|
||||
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();
|
||||
let new_active = if active == idx {
|
||||
if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx }
|
||||
} else if active > idx {
|
||||
active - 1
|
||||
} else {
|
||||
active
|
||||
};
|
||||
tiles.remove(idx);
|
||||
self.active_model_tile
|
||||
.set(new_active.min(tiles.len().saturating_sub(1)));
|
||||
} else {
|
||||
// Isolated tile (shouldn't happen with axis-aligned
|
||||
// splits) — drop it and stretch the first remaining
|
||||
// tile to fill the canvas so we don't leave a hole.
|
||||
tiles.remove(idx);
|
||||
let active = self.active_model_tile.get();
|
||||
self.active_model_tile
|
||||
.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 {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the active Model tile in two. `horizontal` → a horizontal
|
||||
/// divider (top / bottom halves); otherwise a vertical divider (left /
|
||||
/// right). Both halves inherit the active tile's current camera; the
|
||||
|
||||
@@ -108,6 +108,7 @@ pub fn selection_overlay<'a>(
|
||||
ost_points: Vec<OstTrackPoint>,
|
||||
cursor_screen: Point,
|
||||
show_viewcube: bool,
|
||||
tile_edges: Vec<crate::scene::TileEdge>,
|
||||
) -> Element<'a, Message> {
|
||||
canvas(SelectionCanvas {
|
||||
selection,
|
||||
@@ -118,6 +119,7 @@ pub fn selection_overlay<'a>(
|
||||
ost_points,
|
||||
cursor_screen,
|
||||
show_viewcube,
|
||||
tile_edges,
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -133,6 +135,7 @@ struct SelectionCanvas {
|
||||
ost_points: Vec<OstTrackPoint>,
|
||||
cursor_screen: Point,
|
||||
show_viewcube: bool,
|
||||
tile_edges: Vec<crate::scene::TileEdge>,
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for SelectionCanvas {
|
||||
@@ -158,6 +161,43 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hover over a Model-tile divider → resize cursor cue.
|
||||
if !self.tile_edges.is_empty() {
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
const TOL_PX: f32 = 4.0;
|
||||
for e in &self.tile_edges {
|
||||
let (perp, edge_px, span_lo, span_hi, pos_along) = match e.orient {
|
||||
crate::scene::TileEdgeOrient::Vertical => (
|
||||
pos.x,
|
||||
e.coord * bounds.width,
|
||||
e.span.0 * bounds.height,
|
||||
e.span.1 * bounds.height,
|
||||
pos.y,
|
||||
),
|
||||
crate::scene::TileEdgeOrient::Horizontal => (
|
||||
pos.y,
|
||||
e.coord * bounds.height,
|
||||
e.span.0 * bounds.width,
|
||||
e.span.1 * bounds.width,
|
||||
pos.x,
|
||||
),
|
||||
};
|
||||
if (perp - edge_px).abs() <= TOL_PX
|
||||
&& pos_along >= span_lo
|
||||
&& pos_along <= span_hi
|
||||
{
|
||||
return match e.orient {
|
||||
crate::scene::TileEdgeOrient::Vertical => {
|
||||
mouse::Interaction::ResizingHorizontally
|
||||
}
|
||||
crate::scene::TileEdgeOrient::Horizontal => {
|
||||
mouse::Interaction::ResizingVertically
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cursor.is_over(bounds) {
|
||||
mouse::Interaction::Crosshair
|
||||
} else {
|
||||
@@ -175,6 +215,49 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||||
|
||||
// ── Tile dividers (Model-space tiled layout) ──────────────────────
|
||||
// Drawn first so all other overlays sit on top — the line is just
|
||||
// a visual cue for the resize-drag handles.
|
||||
if !self.tile_edges.is_empty() {
|
||||
const DIVIDER: Color = Color {
|
||||
r: 0.40,
|
||||
g: 0.45,
|
||||
b: 0.55,
|
||||
a: 0.85,
|
||||
};
|
||||
for e in &self.tile_edges {
|
||||
let (a, b) = match e.orient {
|
||||
crate::scene::TileEdgeOrient::Vertical => {
|
||||
let x = e.coord * bounds.width;
|
||||
(
|
||||
Point::new(x, e.span.0 * bounds.height),
|
||||
Point::new(x, e.span.1 * bounds.height),
|
||||
)
|
||||
}
|
||||
crate::scene::TileEdgeOrient::Horizontal => {
|
||||
let y = e.coord * bounds.height;
|
||||
(
|
||||
Point::new(e.span.0 * bounds.width, y),
|
||||
Point::new(e.span.1 * bounds.width, y),
|
||||
)
|
||||
}
|
||||
};
|
||||
let path = canvas::Path::new(|p| {
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
});
|
||||
frame.stroke(
|
||||
&path,
|
||||
canvas::Stroke {
|
||||
width: 2.0,
|
||||
style: canvas::Style::Solid(DIVIDER),
|
||||
line_cap: canvas::LineCap::Butt,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid display ──────────────────────────────────────────────────
|
||||
if let Some(ref g) = self.grid {
|
||||
// Clip to the active tile's rectangle so grid lines don't spill
|
||||
|
||||
Reference in new issue
Block a user