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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-27 11:50:11 +03:00
1 parent 8bdf5228d2
commit 495b13ec4f
18 files changed
+1022 -751

No files matched your search

+3
View File
@@ -348,6 +348,9 @@ impl Scene {
*self.model_tiles.borrow_mut() = tiles;
self.active_model_tile.set(0);
*self.camera.borrow_mut() = active_cam;
// Rebuild the pane_grid layout from the restored tile rects so the panes
// match (the renderer / input iterate panes, not tiles).
self.rebuild_panes_from_tiles();
self.camera_generation += 1;
true
}
+366 -435
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)),
}
}
+14 -21
View File
@@ -527,27 +527,10 @@ pub(crate) struct ModelTile {
pub(crate) snap_on: bool,
}
/// 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),
}
/// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible
/// divider width. The renderer derives tile rects through this same spacing so
/// the drawn viewports line up exactly with the pane_grid layout.
pub const TILE_DIVIDER_PX: f32 = 2.0;
/// Shift every vertex of a freshly tessellated `MeshLodSet` into the
/// scene's local f32 space by subtracting `world_offset`. ACIS / SAT
@@ -693,6 +676,14 @@ pub struct Scene {
pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
/// Index of the active model tile (camera input + overlays target it).
pub(crate) active_model_tile: std::cell::Cell<usize>,
/// pane_grid layout tree for the Model tab — the source of truth for the
/// tile split layout, resize and focus. `model_tiles` (the renderer's
/// per-pane data: camera / render-mode / grid) is kept in lock-step with
/// it, and its rects are derived from the pane regions. Each pane's value
/// is the index of its backing `ModelTile`. Paper layout is unaffected.
/// Plain field (not a `RefCell`) so the view can borrow it for the
/// `PaneGrid` widget's lifetime; mutated through `&mut Scene` in update.
pub(crate) model_panes: iced::widget::pane_grid::State<usize>,
pub selection: Rc<RefCell<SelectionState>>,
/// The CAD document — single source of truth for all entities.
pub document: CadDocument,
@@ -921,6 +912,8 @@ impl Scene {
snap_on: false,
}]),
active_model_tile: std::cell::Cell::new(0),
// One pane mapped to tile 0 — matches the single default tile above.
model_panes: iced::widget::pane_grid::State::new(0).0,
selection: Rc::new(RefCell::new(SelectionState::default())),
document: CadDocument::new(),
selected: HashSet::default(),
+5 -3
View File
@@ -2165,8 +2165,11 @@ pub struct MultiPipeline {
}
impl MultiPipeline {
/// Ensure exactly `n` (≥1) inner pipelines exist, creating any missing
/// ones. Extra pipelines beyond `n` are dropped.
/// Ensure at least `n` (≥1) inner pipelines exist, creating any missing
/// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model
/// shader widgets share this storage and each only touches its own slot —
/// truncating mid-frame would destroy another pane's slot. Stale inners (a
/// closed pane, or fewer paper viewports) are harmless idle GPU resources.
pub(crate) fn ensure_len(
&mut self,
device: &wgpu::Device,
@@ -2177,7 +2180,6 @@ impl MultiPipeline {
while self.inners.len() < n {
self.inners.push(Pipeline::new(device, queue, self.format));
}
self.inners.truncate(n);
}
}
+76 -4
View File
@@ -113,6 +113,13 @@ pub struct Primitive {
pub(in crate::scene) viewports: Vec<ViewportData>,
/// Background color used to clear each viewport's MSAA buffer.
pub(in crate::scene) bg_color: [f32; 4],
/// First `MultiPipeline` inner slot this primitive owns. Paper space (one
/// shader widget, many viewports) uses 0. Per-pane Model widgets each own a
/// distinct slot (= their tile index) so several shader widgets can share
/// the type-keyed pipeline storage without clobbering one another — all
/// `prepare` calls run before all `render` calls, so disjoint slots are
/// safe.
pub(in crate::scene) base_slot: usize,
}
/// Flags the render pipeline consumes, derived from
@@ -201,10 +208,10 @@ impl shader::Primitive for Primitive {
let phys = viewport.physical_size();
let full_size = Size::new(phys.width, phys.height);
let scale = viewport.scale_factor() as f32;
pipeline.ensure_len(device, queue, self.viewports.len());
pipeline.ensure_len(device, queue, self.base_slot + self.viewports.len());
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[i];
let inner = &mut pipeline.inners[self.base_slot + i];
// The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion)
// so the camera matrices render at consistent aspect / scale.
@@ -347,7 +354,7 @@ impl shader::Primitive for Primitive {
let clip_right = clip.x + clip.width;
let clip_bottom = clip.y + clip.height;
for (i, vp) in self.viewports.iter().enumerate() {
let Some(inner) = pipeline.inners.get(i) else {
let Some(inner) = pipeline.inners.get(self.base_slot + i) else {
break;
};
// Where the viewport would land on the surface in absolute
@@ -628,7 +635,72 @@ impl Scene {
.collect();
// Empty viewports → blit nothing; the container background (model bg
// or the paper desk colour) stays visible.
Primitive { viewports, bg_color }
Primitive {
viewports,
bg_color,
base_slot: 0,
}
}
/// Build a single-pane Model primitive: the viewport for tile `tile_idx`,
/// filling the shader widget's own `bounds` (= the pane rectangle the
/// `pane_grid` laid out). Each Model pane is its own shader widget, so the
/// camera matrices use the pane aspect for free and the primitive owns
/// pipeline slot `tile_idx`. The active tile renders the live camera /
/// render-mode; the rest use their stored snapshot.
pub(in crate::scene) fn build_viewport_for_pane(
&self,
bounds: Rectangle,
tile_idx: usize,
model_render_mode: acadrust::entities::ViewportRenderMode,
) -> Primitive {
let hover_region = self.viewcube_hover.get();
let canvas = (bounds.width.max(1.0), bounds.height.max(1.0));
let bg_color = [0.0, 0.0, 0.0, 0.0];
let tiles = self.model_tiles.borrow();
let Some(tile) = tiles.get(tile_idx) else {
return Primitive {
viewports: vec![],
bg_color,
base_slot: tile_idx,
};
};
let active = self.active_model_tile.get();
let is_active = tile_idx == active;
let camera = if is_active {
self.camera.borrow().clone()
} else {
tile.camera.clone()
};
let inst = ViewportInstance {
handle: Handle::NULL,
tile_idx: Some(tile_idx),
// Fills the whole widget (= pane); normalized rect is (0,0,1,1).
screen_rect: Rectangle {
x: 0.0,
y: 0.0,
width: canvas.0,
height: canvas.1,
},
camera,
render_mode: if is_active {
model_render_mode
} else {
tile.render_mode
},
active: is_active,
grid_on: tile.grid_on,
paper_sheet: false,
};
let viewports = self
.viewport_data_for(&inst, canvas, hover_region)
.into_iter()
.collect();
Primitive {
viewports,
bg_color,
base_slot: tile_idx,
}
}
/// Build one `ViewportData` from a `ViewportInstance`: gathers the
+29 -2
View File
@@ -19,6 +19,10 @@ pub struct ViewportPane<'a> {
/// Render mode applied to the Model layout's tiles. Paper-space content
/// viewports use the render mode stored on their own viewport entity.
pub render_mode: acadrust::entities::ViewportRenderMode,
/// `Some(tile_idx)` → this widget renders a single Model pane (one shader
/// per `pane_grid` pane, filling its own bounds). `None` → the unified
/// full-canvas path (paper layout, or the whole-canvas Model fallback).
pub pane: Option<usize>,
}
impl<'a> ViewportPane<'a> {
@@ -31,6 +35,23 @@ impl<'a> ViewportPane<'a> {
scene,
show_viewcube,
render_mode,
pane: None,
}
}
/// One Model `pane_grid` pane: renders just `tile_idx` into this widget's
/// own bounds (the pane rectangle).
pub fn for_pane(
scene: &'a Scene,
show_viewcube: bool,
render_mode: acadrust::entities::ViewportRenderMode,
tile_idx: usize,
) -> Self {
Self {
scene,
show_viewcube,
render_mode,
pane: Some(tile_idx),
}
}
}
@@ -47,8 +68,14 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for ViewportPane<'a>
_cursor: mouse::Cursor,
bounds: Rectangle,
) -> Self::Primitive {
self.scene
.build_viewports(bounds, self.render_mode, state.hover_region)
match self.pane {
Some(idx) => self
.scene
.build_viewport_for_pane(bounds, idx, self.render_mode),
None => self
.scene
.build_viewports(bounds, self.render_mode, state.hover_region),
}
}
fn update(