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:
Hakan Seven committed 2026-05-28 18:42:39 +03:00
1 parent e3c962bdd1
commit 39eab9a4c4
5 files changed
+550 -2

No files matched your search

+83
View File
@@ -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