refactor(viewport): migrate model-space tiling to iced pane_grid
Replace the custom model_tiles tiling with iced 0.14 pane_grid as the layout source of truth, while paper-space floating viewports are left untouched (they overlap/free-place, which pane_grid cannot model). Each Model pane is its own shader widget rendering its camera into the pane bounds; the renderer Primitive carries a base_slot so several shader widgets share the type-keyed MultiPipeline storage on disjoint slots. A second pane_grid of transparent mouse_areas (plus on_resize) layers above the crosshair overlay to receive input — the overlay's Hidden cursor interaction otherwise levitates the cursor and starves any layer beneath it. The active pane follows the cursor (hover-activate), and grid / crosshair / UCS icon render per pane while the ViewCube and render-mode picker stay on the active pane only. Split / close / VPORTS presets and the VPort table round-trip now drive pane_grid (guillotine reconstruction on load). Adds drag-to-swap via a controls bar handle with a ghost-card preview, visible pane dividers from the pane_grid split regions, a default model background of RGB(33,40,48), and removes all the superseded custom-tile code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8bdf5228d2
commit
495b13ec4f
18 changed files with 1004 additions and 733 deletions
|
|
@ -121,420 +121,37 @@ impl Scene {
|
|||
}
|
||||
}
|
||||
}
|
||||
/// 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 on either side can shrink below the minimum — dragging a
|
||||
/// divider to the screen edge stops at that minimum instead of
|
||||
/// closing the pane (use the close button for that).
|
||||
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);
|
||||
}
|
||||
if (t.rect.x - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.x + t.rect.width - min_size);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi.max(lo))
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (t.rect.y - old_coord).abs() < TILE_EPS {
|
||||
hi = hi.min(t.rect.y + t.rect.height - min_size);
|
||||
}
|
||||
}
|
||||
new_coord.clamp(lo, hi.max(lo))
|
||||
}
|
||||
/// Rebuild the `pane_grid` layout from the current `model_tiles` rects
|
||||
/// (each pane value = its tile index) — used after loading a tiled config
|
||||
/// from the VPort table so the panes match the restored tiles.
|
||||
pub fn rebuild_panes_from_tiles(&mut self) {
|
||||
let items: Vec<(usize, iced::Rectangle)> = self
|
||||
.model_tiles
|
||||
.borrow()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| (i, t.rect))
|
||||
.collect();
|
||||
let full = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the active Model tile, absorbing its area into the
|
||||
/// neighbour that shares the longest contact edge and rebinding the
|
||||
/// live camera to that neighbour. No-op with fewer than two tiles.
|
||||
pub fn close_active_model_tile(&self) {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if tiles.len() < 2 {
|
||||
return;
|
||||
}
|
||||
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 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)));
|
||||
let config = if items.is_empty() {
|
||||
iced::widget::pane_grid::Configuration::Pane(0)
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
config_from_rects(&items, full)
|
||||
};
|
||||
self.model_panes = iced::widget::pane_grid::State::with_configuration(config);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// active tile stays the first half. No-op outside the Model layout.
|
||||
pub fn split_active_model_tile(&self, horizontal: bool) {
|
||||
if self.current_layout != "Model" {
|
||||
return;
|
||||
}
|
||||
let cam_now = self.camera.borrow().clone();
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
let r = tiles[active].rect;
|
||||
let (a, b) = if horizontal {
|
||||
(
|
||||
iced::Rectangle { height: r.height / 2.0, ..r },
|
||||
iced::Rectangle {
|
||||
y: r.y + r.height / 2.0,
|
||||
height: r.height / 2.0,
|
||||
..r
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
iced::Rectangle { width: r.width / 2.0, ..r },
|
||||
iced::Rectangle {
|
||||
x: r.x + r.width / 2.0,
|
||||
width: r.width / 2.0,
|
||||
..r
|
||||
},
|
||||
)
|
||||
};
|
||||
let mode = tiles[active].render_mode;
|
||||
let (grid_on, snap_on) = (tiles[active].grid_on, tiles[active].snap_on);
|
||||
tiles[active] = ModelTile {
|
||||
rect: a,
|
||||
camera: cam_now.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
};
|
||||
tiles.insert(
|
||||
active + 1,
|
||||
ModelTile {
|
||||
rect: b,
|
||||
camera: cam_now,
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Make the Model tile containing normalized point `(nx, ny)` active,
|
||||
/// swapping cameras so the live `Scene::camera` follows the new tile.
|
||||
/// Returns `true` when the active tile changed. No-op outside Model.
|
||||
pub fn set_active_model_tile_at(&self, nx: f32, ny: f32) -> bool {
|
||||
if self.current_layout != "Model" {
|
||||
return false;
|
||||
}
|
||||
let new = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
tiles.iter().position(|t| {
|
||||
nx >= t.rect.x
|
||||
&& nx < t.rect.x + t.rect.width
|
||||
&& ny >= t.rect.y
|
||||
&& ny < t.rect.y + t.rect.height
|
||||
})
|
||||
};
|
||||
let Some(new) = new else { return false };
|
||||
let old = self.active_model_tile.get();
|
||||
if new == old {
|
||||
return false;
|
||||
}
|
||||
// Stash the live camera into the outgoing tile, load the incoming.
|
||||
let incoming = {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if let Some(t) = tiles.get_mut(old) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
tiles.get(new).map(|t| t.camera.clone())
|
||||
};
|
||||
if let Some(cam) = incoming {
|
||||
*self.camera.borrow_mut() = cam;
|
||||
}
|
||||
self.active_model_tile.set(new);
|
||||
// Caller bumps camera_generation (it needs &mut Scene).
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the Model tiled layout with the given normalized rectangles
|
||||
/// (each in 0..1). Every tile inherits the current camera; the first
|
||||
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
||||
pub fn set_model_tile_layout(&self, rects: Vec<iced::Rectangle>) {
|
||||
let cam_now = self.camera.borrow().clone();
|
||||
// Every new pane inherits the active tile's current visual style.
|
||||
/// Replace the Model pane layout with a `pane_grid` configuration (VPORTS
|
||||
/// presets). Each resulting pane inherits the active tile's camera / style;
|
||||
/// pane values are compacted to `0..N` and `model_tiles` rebuilt to match.
|
||||
pub fn set_model_panes(&mut self, config: iced::widget::pane_grid::Configuration<usize>) {
|
||||
let cam = self.camera.borrow().clone();
|
||||
let (mode, grid_on, snap_on) = {
|
||||
let tiles = self.model_tiles.borrow();
|
||||
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
|
||||
|
|
@ -547,35 +164,264 @@ impl Scene {
|
|||
false,
|
||||
))
|
||||
};
|
||||
let tiles: Vec<ModelTile> = rects
|
||||
.into_iter()
|
||||
.map(|rect| ModelTile {
|
||||
rect,
|
||||
camera: cam_now.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
})
|
||||
.collect();
|
||||
*self.model_tiles.borrow_mut() = if tiles.is_empty() {
|
||||
vec![ModelTile {
|
||||
self.model_panes = iced::widget::pane_grid::State::with_configuration(config);
|
||||
let order: Vec<iced::widget::pane_grid::Pane> =
|
||||
self.model_panes.iter().map(|(p, _)| *p).collect();
|
||||
let mut tiles = Vec::with_capacity(order.len());
|
||||
for (i, pane) in order.iter().enumerate() {
|
||||
if let Some(v) = self.model_panes.get_mut(*pane) {
|
||||
*v = i;
|
||||
}
|
||||
tiles.push(ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
camera: cam_now,
|
||||
camera: cam.clone(),
|
||||
render_mode: mode,
|
||||
grid_on,
|
||||
snap_on,
|
||||
}]
|
||||
} else {
|
||||
tiles
|
||||
};
|
||||
});
|
||||
}
|
||||
*self.model_tiles.borrow_mut() = tiles;
|
||||
self.active_model_tile.set(0);
|
||||
}
|
||||
|
||||
/// Split the active Model pane through `pane_grid`, adding a new pane that
|
||||
/// inherits the active pane's camera / render-mode / grid. `horizontal`
|
||||
/// true → a horizontal divider (panes stacked); false → vertical (panes
|
||||
/// side by side). The active pane stays active.
|
||||
pub fn split_active_pane(&mut self, horizontal: bool) {
|
||||
use iced::widget::pane_grid::Axis;
|
||||
let active_idx = self.active_model_tile.get();
|
||||
// Keep the active tile's stored camera current before cloning it.
|
||||
if let Some(t) = self.model_tiles.borrow_mut().get_mut(active_idx) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
let Some(active_pane) = self
|
||||
.model_panes
|
||||
.iter()
|
||||
.find(|(_, &v)| v == active_idx)
|
||||
.map(|(p, _)| *p)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(clone) = self.model_tiles.borrow().get(active_idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
let new_val = self.model_tiles.borrow().len();
|
||||
self.model_tiles.borrow_mut().push(clone);
|
||||
let axis = if horizontal {
|
||||
Axis::Horizontal
|
||||
} else {
|
||||
Axis::Vertical
|
||||
};
|
||||
if self.model_panes.split(axis, active_pane, new_val).is_none() {
|
||||
self.model_tiles.borrow_mut().pop();
|
||||
return;
|
||||
}
|
||||
self.compact_panes(active_pane);
|
||||
}
|
||||
|
||||
/// Close the active Model pane (no-op on the last pane); the sibling that
|
||||
/// absorbs the space becomes active and its camera goes live.
|
||||
pub fn close_active_pane(&mut self) {
|
||||
if self.model_panes.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
let active_idx = self.active_model_tile.get();
|
||||
if let Some(t) = self.model_tiles.borrow_mut().get_mut(active_idx) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
let Some(active_pane) = self
|
||||
.model_panes
|
||||
.iter()
|
||||
.find(|(_, &v)| v == active_idx)
|
||||
.map(|(p, _)| *p)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some((_, sibling)) = self.model_panes.close(active_pane) {
|
||||
self.compact_panes(sibling);
|
||||
let cam = self
|
||||
.model_tiles
|
||||
.borrow()
|
||||
.get(self.active_model_tile.get())
|
||||
.map(|t| t.camera.clone());
|
||||
if let Some(c) = cam {
|
||||
*self.camera.borrow_mut() = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap two Model panes (by tile index) in the `pane_grid` layout — each
|
||||
/// viewport moves to the other's position, keeping its own camera / style.
|
||||
pub fn swap_model_panes(&mut self, a: usize, b: usize) {
|
||||
if a == b {
|
||||
return;
|
||||
}
|
||||
let pa = self.model_panes.iter().find(|(_, &v)| v == a).map(|(p, _)| *p);
|
||||
let pb = self.model_panes.iter().find(|(_, &v)| v == b).map(|(p, _)| *p);
|
||||
if let (Some(pa), Some(pb)) = (pa, pb) {
|
||||
self.model_panes.swap(pa, pb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renumber pane values to a compact `0..N` in layout order and rebuild
|
||||
/// `model_tiles` to match (preserving each pane's data), so the pane value
|
||||
/// is always a valid `model_tiles` index. `active_pane` becomes the new
|
||||
/// active tile.
|
||||
fn compact_panes(&mut self, active_pane: iced::widget::pane_grid::Pane) {
|
||||
let old = self.model_tiles.borrow().clone();
|
||||
let order: Vec<iced::widget::pane_grid::Pane> =
|
||||
self.model_panes.iter().map(|(p, _)| *p).collect();
|
||||
let fallback = old
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
camera: self.camera.borrow().clone(),
|
||||
render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D,
|
||||
grid_on: false,
|
||||
snap_on: false,
|
||||
});
|
||||
let mut new_tiles = Vec::with_capacity(order.len());
|
||||
let mut new_active = 0usize;
|
||||
for (new_idx, pane) in order.iter().enumerate() {
|
||||
let old_idx = *self.model_panes.get(*pane).unwrap_or(&0);
|
||||
new_tiles.push(old.get(old_idx).cloned().unwrap_or_else(|| fallback.clone()));
|
||||
if let Some(v) = self.model_panes.get_mut(*pane) {
|
||||
*v = new_idx;
|
||||
}
|
||||
if *pane == active_pane {
|
||||
new_active = new_idx;
|
||||
}
|
||||
}
|
||||
*self.model_tiles.borrow_mut() = new_tiles;
|
||||
self.active_model_tile.set(new_active);
|
||||
}
|
||||
|
||||
/// Make Model tile `idx` active, stashing the live camera into the outgoing
|
||||
/// tile and loading the incoming one. Returns `true` if the active tile
|
||||
/// changed. The caller bumps `camera_generation` and syncs the display.
|
||||
pub fn set_active_model_tile(&self, idx: usize) -> bool {
|
||||
if self.current_layout != "Model" {
|
||||
return false;
|
||||
}
|
||||
let old = self.active_model_tile.get();
|
||||
if idx == old || idx >= self.model_tiles.borrow().len() {
|
||||
return false;
|
||||
}
|
||||
let incoming = {
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
if let Some(t) = tiles.get_mut(old) {
|
||||
t.camera = self.camera.borrow().clone();
|
||||
}
|
||||
tiles.get(idx).map(|t| t.camera.clone())
|
||||
};
|
||||
if let Some(cam) = incoming {
|
||||
*self.camera.borrow_mut() = cam;
|
||||
}
|
||||
self.active_model_tile.set(idx);
|
||||
true
|
||||
}
|
||||
|
||||
/// Divider bars between Model panes, as pixel rectangles within a
|
||||
/// `(vw, vh)` canvas — derived from the `pane_grid` split regions so they
|
||||
/// land exactly in the spacing gaps the renderer leaves. Each bar is
|
||||
/// `TILE_DIVIDER_PX` thick. Empty outside Model / for a single pane.
|
||||
pub fn model_pane_dividers(&self, vw: f32, vh: f32) -> Vec<iced::Rectangle> {
|
||||
use iced::widget::pane_grid::Axis;
|
||||
if self.current_layout != "Model" || vw < 1.0 || vh < 1.0 {
|
||||
return vec![];
|
||||
}
|
||||
let half = TILE_DIVIDER_PX * 0.5;
|
||||
self.model_panes
|
||||
.layout()
|
||||
.split_regions(TILE_DIVIDER_PX, 0.0, iced::Size::new(vw, vh))
|
||||
.values()
|
||||
.map(|(axis, region, ratio)| match axis {
|
||||
Axis::Vertical => {
|
||||
let x = region.x + ratio * region.width;
|
||||
iced::Rectangle {
|
||||
x: x - half,
|
||||
y: region.y,
|
||||
width: TILE_DIVIDER_PX,
|
||||
height: region.height,
|
||||
}
|
||||
}
|
||||
Axis::Horizontal => {
|
||||
let y = region.y + ratio * region.height;
|
||||
iced::Rectangle {
|
||||
x: region.x,
|
||||
y: y - half,
|
||||
width: region.width,
|
||||
height: TILE_DIVIDER_PX,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Top-left pixel corner of Model pane `idx` within a `(vw, vh)` canvas,
|
||||
/// derived from the `pane_grid` layout (so it matches the rendered pane).
|
||||
pub fn pane_origin_px(&self, idx: usize, vw: f32, vh: f32) -> iced::Point {
|
||||
if vw < 1.0 || vh < 1.0 {
|
||||
return iced::Point::ORIGIN;
|
||||
}
|
||||
let regions = self.model_panes.layout().pane_regions(
|
||||
TILE_DIVIDER_PX,
|
||||
0.0,
|
||||
iced::Size::new(vw, vh),
|
||||
);
|
||||
self.model_panes
|
||||
.iter()
|
||||
.find(|(_, &v)| v == idx)
|
||||
.and_then(|(pane, _)| regions.get(pane))
|
||||
.map(|r| iced::Point::new(r.x, r.y))
|
||||
.unwrap_or(iced::Point::ORIGIN)
|
||||
}
|
||||
|
||||
/// Replace the Model tiled layout with the given normalized rectangles
|
||||
/// (each in 0..1). Every tile inherits the current camera; the first
|
||||
/// tile becomes active. Used by VPORTS presets and `reset_model_tiles`.
|
||||
/// Derive `model_tiles` rects from the `pane_grid` layout (the layout
|
||||
/// source of truth) for a canvas of `(canvas_w, canvas_h)` pixels — each
|
||||
/// pane's value is its `model_tiles` index. Called before the renderer /
|
||||
/// hit-test read tile rects so they track the live pane_grid layout and any
|
||||
/// in-flight resize, with the divider gap applied exactly as pane_grid draws
|
||||
/// it. No-op outside the Model layout.
|
||||
#[allow(dead_code)]
|
||||
pub fn sync_tiles_from_panes(&self, canvas_w: f32, canvas_h: f32) {
|
||||
if self.current_layout != "Model" || canvas_w < 1.0 || canvas_h < 1.0 {
|
||||
return;
|
||||
}
|
||||
let regions = self.model_panes.layout().pane_regions(
|
||||
TILE_DIVIDER_PX,
|
||||
0.0,
|
||||
iced::Size::new(canvas_w, canvas_h),
|
||||
);
|
||||
let mut tiles = self.model_tiles.borrow_mut();
|
||||
for (pane, &idx) in self.model_panes.iter() {
|
||||
if let (Some(r), Some(tile)) = (regions.get(pane), tiles.get_mut(idx)) {
|
||||
tile.rect = iced::Rectangle {
|
||||
x: r.x / canvas_w,
|
||||
y: r.y / canvas_h,
|
||||
width: r.width / canvas_w,
|
||||
height: r.height / canvas_h,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Screen-pixel rectangle of the active Model tile within a canvas of
|
||||
/// `(vw, vh)`. Full canvas outside the Model layout or for a single
|
||||
/// tile. Used to map cursor coordinates into the active tile so pick /
|
||||
|
|
@ -609,14 +455,99 @@ impl Scene {
|
|||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
/// Reconstruct a `pane_grid` configuration from a set of (tile-index, rect)
|
||||
/// items covering `region`, by recursively finding a full vertical or
|
||||
/// horizontal guillotine cut. AutoCAD tiled configs (and anything pane_grid
|
||||
/// produces) are guillotine layouts, so this round-trips them. A non-guillotine
|
||||
/// set falls back to chaining panes so nothing is lost.
|
||||
fn config_from_rects(
|
||||
items: &[(usize, iced::Rectangle)],
|
||||
region: iced::Rectangle,
|
||||
) -> iced::widget::pane_grid::Configuration<usize> {
|
||||
use iced::widget::pane_grid::{Axis, Configuration as C};
|
||||
if items.len() == 1 {
|
||||
return C::Pane(items[0].0);
|
||||
}
|
||||
let eps = 1e-3;
|
||||
// Try a vertical cut: a boundary x where every rect is wholly left or right.
|
||||
let mut cuts: Vec<f32> = items.iter().map(|(_, r)| r.x + r.width).collect();
|
||||
cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
for &x in &cuts {
|
||||
if x <= region.x + eps || x >= region.x + region.width - eps {
|
||||
continue;
|
||||
}
|
||||
let split_ok = items
|
||||
.iter()
|
||||
.all(|(_, r)| r.x + r.width <= x + eps || r.x >= x - eps);
|
||||
if !split_ok {
|
||||
continue;
|
||||
}
|
||||
let (a, b): (Vec<_>, Vec<_>) = items
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(|(_, r)| r.x + r.width <= x + eps);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ra = iced::Rectangle {
|
||||
width: x - region.x,
|
||||
..region
|
||||
};
|
||||
let rb = iced::Rectangle {
|
||||
x,
|
||||
width: region.x + region.width - x,
|
||||
..region
|
||||
};
|
||||
return C::Split {
|
||||
axis: Axis::Vertical,
|
||||
ratio: ((x - region.x) / region.width).clamp(0.05, 0.95),
|
||||
a: Box::new(config_from_rects(&a, ra)),
|
||||
b: Box::new(config_from_rects(&b, rb)),
|
||||
};
|
||||
}
|
||||
// Try a horizontal cut: a boundary y where every rect is wholly above/below.
|
||||
let mut cuts: Vec<f32> = items.iter().map(|(_, r)| r.y + r.height).collect();
|
||||
cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
for &y in &cuts {
|
||||
if y <= region.y + eps || y >= region.y + region.height - eps {
|
||||
continue;
|
||||
}
|
||||
let split_ok = items
|
||||
.iter()
|
||||
.all(|(_, r)| r.y + r.height <= y + eps || r.y >= y - eps);
|
||||
if !split_ok {
|
||||
continue;
|
||||
}
|
||||
let (a, b): (Vec<_>, Vec<_>) = items
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(|(_, r)| r.y + r.height <= y + eps);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ra = iced::Rectangle {
|
||||
height: y - region.y,
|
||||
..region
|
||||
};
|
||||
let rb = iced::Rectangle {
|
||||
y,
|
||||
height: region.y + region.height - y,
|
||||
..region
|
||||
};
|
||||
return C::Split {
|
||||
axis: Axis::Horizontal,
|
||||
ratio: ((y - region.y) / region.height).clamp(0.05, 0.95),
|
||||
a: Box::new(config_from_rects(&a, ra)),
|
||||
b: Box::new(config_from_rects(&b, rb)),
|
||||
};
|
||||
}
|
||||
// Non-guillotine fallback: peel the first pane off the rest.
|
||||
let (first, rest) = items.split_first().unwrap();
|
||||
C::Split {
|
||||
axis: Axis::Vertical,
|
||||
ratio: 0.5,
|
||||
a: Box::new(C::Pane(first.0)),
|
||||
b: Box::new(config_from_rects(rest, region)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue