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:
parent
e3c962bdd1
commit
39eab9a4c4
5 changed files with 550 additions and 2 deletions
|
|
@ -11,7 +11,18 @@ mod view;
|
||||||
use document::DocumentTab;
|
use document::DocumentTab;
|
||||||
|
|
||||||
use crate::modules::ModuleEvent;
|
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::snap::Snapper;
|
||||||
use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar};
|
use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar};
|
||||||
use acadrust::types::{Color as AcadColor, LineWeight};
|
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
|
/// `true` after a bare `VPORTS` in model space — the next command-line
|
||||||
/// entry is treated as the tiled-config option (SIngle/2H/2V/4).
|
/// entry is treated as the tiled-config option (SIngle/2H/2V/4).
|
||||||
awaiting_vports: bool,
|
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 the UCS icon in the bottom-left corner of model space (UCSICON).
|
||||||
show_ucs_icon: bool,
|
show_ucs_icon: bool,
|
||||||
/// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE).
|
/// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE).
|
||||||
|
|
@ -747,6 +762,7 @@ impl OpenCADStudio {
|
||||||
show_grid: false,
|
show_grid: false,
|
||||||
dyn_input: true,
|
dyn_input: true,
|
||||||
awaiting_vports: false,
|
awaiting_vports: false,
|
||||||
|
tile_drag: None,
|
||||||
show_ucs_icon: true,
|
show_ucs_icon: true,
|
||||||
show_viewcube: true,
|
show_viewcube: true,
|
||||||
show_properties: true,
|
show_properties: true,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ use super::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||||
use crate::modules::ModuleEvent;
|
use crate::modules::ModuleEvent;
|
||||||
use crate::scene::grip::{find_hit_grip, find_hit_grip_paper, GripEdit};
|
use crate::scene::grip::{find_hit_grip, find_hit_grip_paper, GripEdit};
|
||||||
use crate::scene::object::GripApply;
|
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 crate::ui::PropertiesPanel;
|
||||||
use acadrust::types::Color as AcadColor;
|
use acadrust::types::Color as AcadColor;
|
||||||
use acadrust::{EntityType as AcadEntityType, Handle};
|
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||||
|
|
@ -15,6 +17,20 @@ use iced::window;
|
||||||
use iced::{mouse, Task};
|
use iced::{mouse, Task};
|
||||||
|
|
||||||
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;
|
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 {
|
fn format_size(bytes: u64) -> String {
|
||||||
const KB: f64 = 1024.0;
|
const KB: f64 = 1024.0;
|
||||||
|
|
@ -1646,6 +1662,35 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
Message::ViewportMove(p) => {
|
Message::ViewportMove(p) => {
|
||||||
let i = self.active_tab;
|
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
|
// Keep the ViewCube hover in sync as the cursor leaves the
|
||||||
// hit-area overlay and moves over the rest of the viewport.
|
// hit-area overlay and moves over the rest of the viewport.
|
||||||
// `hover_id` returns None outside the cube box, which clears
|
// `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
|
// Tiled Model layout: clicking a non-active tile activates
|
||||||
// it (swapping in its camera) instead of selecting / drawing.
|
// it (swapping in its camera) instead of selecting / drawing.
|
||||||
if self.tabs[i].active_cmd.is_none() && vw > 1.0 && vh > 1.0 {
|
if self.tabs[i].active_cmd.is_none() && vw > 1.0 && vh > 1.0 {
|
||||||
|
|
@ -2165,6 +2237,22 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
Message::ViewportLeftRelease => {
|
Message::ViewportLeftRelease => {
|
||||||
let i = self.active_tab;
|
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 (p, is_click, is_down) = {
|
||||||
let sel = self.tabs[i].scene.selection.borrow();
|
let sel = self.tabs[i].scene.selection.borrow();
|
||||||
let p = match sel.last_move_pos {
|
let p = match sel.last_move_pos {
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,13 @@ impl OpenCADStudio {
|
||||||
vec![]
|
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(
|
overlay::selection_overlay(
|
||||||
sel,
|
sel,
|
||||||
snap_info,
|
snap_info,
|
||||||
|
|
@ -380,6 +387,7 @@ impl OpenCADStudio {
|
||||||
ost_points,
|
ost_points,
|
||||||
tab.last_cursor_screen,
|
tab.last_cursor_screen,
|
||||||
!is_paper && self.show_viewcube,
|
!is_paper && self.show_viewcube,
|
||||||
|
tile_edges,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
353
src/scene/mod.rs
353
src/scene/mod.rs
|
|
@ -439,6 +439,39 @@ pub(crate) struct ModelTile {
|
||||||
pub(crate) camera: Camera,
|
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 struct Scene {
|
||||||
pub camera: Rc<RefCell<Camera>>,
|
pub camera: Rc<RefCell<Camera>>,
|
||||||
/// Model-space tiled viewport layout. One full-window tile by default;
|
/// Model-space tiled viewport layout. One full-window tile by default;
|
||||||
|
|
@ -4463,6 +4496,326 @@ impl Scene {
|
||||||
|
|
||||||
// ── Paper-space coordinate helpers ───────────────────────────────────
|
// ── 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
|
/// Split the active Model tile in two. `horizontal` → a horizontal
|
||||||
/// divider (top / bottom halves); otherwise a vertical divider (left /
|
/// divider (top / bottom halves); otherwise a vertical divider (left /
|
||||||
/// right). Both halves inherit the active tile's current camera; the
|
/// right). Both halves inherit the active tile's current camera; the
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ pub fn selection_overlay<'a>(
|
||||||
ost_points: Vec<OstTrackPoint>,
|
ost_points: Vec<OstTrackPoint>,
|
||||||
cursor_screen: Point,
|
cursor_screen: Point,
|
||||||
show_viewcube: bool,
|
show_viewcube: bool,
|
||||||
|
tile_edges: Vec<crate::scene::TileEdge>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
canvas(SelectionCanvas {
|
canvas(SelectionCanvas {
|
||||||
selection,
|
selection,
|
||||||
|
|
@ -118,6 +119,7 @@ pub fn selection_overlay<'a>(
|
||||||
ost_points,
|
ost_points,
|
||||||
cursor_screen,
|
cursor_screen,
|
||||||
show_viewcube,
|
show_viewcube,
|
||||||
|
tile_edges,
|
||||||
})
|
})
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
|
|
@ -133,6 +135,7 @@ struct SelectionCanvas {
|
||||||
ost_points: Vec<OstTrackPoint>,
|
ost_points: Vec<OstTrackPoint>,
|
||||||
cursor_screen: Point,
|
cursor_screen: Point,
|
||||||
show_viewcube: bool,
|
show_viewcube: bool,
|
||||||
|
tile_edges: Vec<crate::scene::TileEdge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl canvas::Program<Message> for SelectionCanvas {
|
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) {
|
if cursor.is_over(bounds) {
|
||||||
mouse::Interaction::Crosshair
|
mouse::Interaction::Crosshair
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -175,6 +215,49 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||||
) -> Vec<canvas::Geometry> {
|
) -> Vec<canvas::Geometry> {
|
||||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
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 ──────────────────────────────────────────────────
|
// ── Grid display ──────────────────────────────────────────────────
|
||||||
if let Some(ref g) = self.grid {
|
if let Some(ref g) = self.grid {
|
||||||
// Clip to the active tile's rectangle so grid lines don't spill
|
// Clip to the active tile's rectangle so grid lines don't spill
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue