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:
Hakan Seven 2026-06-27 11:50:11 +03:00
commit 495b13ec4f
18 changed files with 1004 additions and 733 deletions

View file

@ -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)),
}
}