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

+116 -91
View File
@@ -181,22 +181,26 @@ pub fn selection_overlay<'a>(
selection: SelectionState,
snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>,
ucs_icon: Option<UcsIconParams>,
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
cursor_screen: Point,
show_viewcube: bool,
tile_edges: Vec<crate::scene::TileEdge>,
dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>,
pane_drop_rect: Option<iced::Rectangle>,
pan_mode: bool,
) -> Element<'a, Message> {
canvas(SelectionCanvas {
selection,
snap,
grips,
ucs_icon,
ucs_icons,
ost_points,
cursor_screen,
show_viewcube,
tile_edges,
dividers,
pane_move_rect,
pane_drop_rect,
pan_mode,
})
.width(Length::Fill)
@@ -208,53 +212,41 @@ struct SelectionCanvas {
selection: SelectionState,
snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>,
ucs_icon: Option<UcsIconParams>,
/// One UCS icon per Model pane (each viewport shows its own at its origin);
/// a single entry for paper / floating-viewport. Only the active pane's
/// entry carries hover/selected (grips).
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
cursor_screen: Point,
show_viewcube: bool,
tile_edges: Vec<crate::scene::TileEdge>,
/// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
/// as filled lines and used to suppress the crosshair over a divider.
dividers: Vec<iced::Rectangle>,
/// When a pane move is armed (drag handle pressed), the source pane's rect
/// (px) — dimmed, with a ghost card dragged along under the cursor.
pane_move_rect: Option<iced::Rectangle>,
/// The pane under the cursor during a pane move (drop target), highlighted.
pane_drop_rect: Option<iced::Rectangle>,
/// Interactive PAN mode: the crosshair is hidden and the cursor becomes a
/// hand so the viewport reads as a draggable surface.
pan_mode: bool,
}
impl SelectionCanvas {
/// Returns the orientation of the Model-tile divider the cursor sits
/// on (within a few pixels of perpendicular distance, inside the
/// edge's span). Used by both `mouse_interaction` (to pick a resize
/// cursor) and `draw` (to suppress the CAD crosshair).
fn tile_edge_under(
&self,
cursor: mouse::Cursor,
bounds: iced::Rectangle,
) -> Option<crate::scene::TileEdgeOrient> {
const TOL_PX: f32 = 4.0;
let pos = cursor.position_in(bounds)?;
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 Some(e.orient);
}
}
None
/// True when the cursor sits on a Model-pane divider (within a few px), so
/// `draw` can suppress the CAD crosshair there. The resize cursor itself is
/// supplied by the input pane_grid layered above.
fn divider_under(&self, cursor: mouse::Cursor, bounds: iced::Rectangle) -> bool {
const TOL_PX: f32 = 3.0;
let Some(pos) = cursor.position_in(bounds) else {
return false;
};
self.dividers.iter().any(|d| {
pos.x >= d.x - TOL_PX
&& pos.x <= d.x + d.width + TOL_PX
&& pos.y >= d.y - TOL_PX
&& pos.y <= d.y + d.height + TOL_PX
})
}
}
@@ -290,20 +282,9 @@ impl canvas::Program<Message> for SelectionCanvas {
}
}
}
// Hover over a Model-tile divider → resize cursor cue. The
// system cursor is intentionally shown here so the OS arrow gives
// its own resize affordance; the draw step suppresses the custom
// CAD crosshair while we're over a divider.
if let Some(orient) = self.tile_edge_under(cursor, bounds) {
return match orient {
crate::scene::TileEdgeOrient::Vertical => {
mouse::Interaction::ResizingHorizontally
}
crate::scene::TileEdgeOrient::Horizontal => {
mouse::Interaction::ResizingVertically
}
};
}
// The resize cursor over a divider is supplied by the input pane_grid
// layered above this overlay; `draw` only suppresses the CAD crosshair
// there (see `divider_under`).
// Over the viewport (no divider, no viewcube): hide the system
// cursor entirely. `Interaction::None` would let the stack fall
// through to a sibling — `Hidden` is the explicit "no cursor"
@@ -325,43 +306,87 @@ impl canvas::Program<Message> for SelectionCanvas {
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
// ── Tile dividers (Model-space tiled layout) ──────────────────────
// Drawn first so all other overlays sit on top — the line is just
// a visual cue for the resize-drag handles.
if !self.tile_edges.is_empty() {
// ── Pane dividers (Model-space tiled layout) ──────────────────────
// Filled bars in the pane_grid spacing gaps, so adjacent panes read as
// distinct viewports. Drawn first so all other overlays sit on top.
if !self.dividers.is_empty() {
const DIVIDER: Color = Color {
r: 0.40,
g: 0.45,
b: 0.55,
a: 0.85,
r: 0.46,
g: 0.52,
b: 0.62,
a: 1.0,
};
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);
});
for d in &self.dividers {
let bar = canvas::Path::rectangle(
Point::new(d.x, d.y),
iced::Size::new(d.width.max(1.0), d.height.max(1.0)),
);
frame.fill(&bar, DIVIDER);
}
}
// ── Pane move (drag-to-swap) ──────────────────────────────────────
// While armed: dim the lifted source pane, highlight the drop target
// under the cursor, and drag a translucent ghost card along the cursor
// so the pane is visibly "moving".
if let Some(src) = self.pane_move_rect {
let accent = Color {
r: 0.30,
g: 0.62,
b: 1.0,
a: 1.0,
};
// Source pane: dimmed + dashed-feel outline (it has been lifted).
let src_path =
canvas::Path::rectangle(Point::new(src.x, src.y), iced::Size::new(src.width, src.height));
frame.fill(
&src_path,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.28,
},
);
frame.stroke(
&src_path,
canvas::Stroke {
width: 1.5,
style: canvas::Style::Solid(Color { a: 0.6, ..accent }),
..Default::default()
},
);
// Drop target pane: bright fill + outline.
if let Some(dst) = self.pane_drop_rect {
let dst_path = canvas::Path::rectangle(
Point::new(dst.x, dst.y),
iced::Size::new(dst.width, dst.height),
);
frame.fill(&dst_path, Color { a: 0.16, ..accent });
frame.stroke(
&path,
&dst_path,
canvas::Stroke {
width: 2.5,
style: canvas::Style::Solid(accent),
..Default::default()
},
);
}
// Ghost card dragged under the cursor — a 0.32× preview of the
// source pane, centred on the cursor.
if let Some(c) = self.selection.last_move_pos {
let gw = (src.width * 0.32).clamp(60.0, 280.0);
let gh = (src.height * 0.32).clamp(40.0, 200.0);
let g = canvas::Path::rectangle(
Point::new(c.x - gw * 0.5, c.y - gh * 0.5),
iced::Size::new(gw, gh),
);
frame.fill(&g, Color { a: 0.30, ..accent });
frame.stroke(
&g,
canvas::Stroke {
width: 2.0,
style: canvas::Style::Solid(DIVIDER),
line_cap: canvas::LineCap::Butt,
style: canvas::Style::Solid(Color { a: 0.95, ..accent }),
..Default::default()
},
);
@@ -799,7 +824,7 @@ impl canvas::Program<Message> for SelectionCanvas {
// Over a Model-tile divider the OS cursor switches to a resize
// arrow (see `mouse_interaction`); drawing the CAD crosshair on
// top of it would double up the visual feedback.
let over_divider = self.tile_edge_under(cursor, bounds).is_some();
let over_divider = self.divider_under(cursor, bounds);
// PAN mode replaces the crosshair with a hand cursor.
if !over_viewcube && !over_divider && !self.pan_mode {
if let Some(cp) = self.selection.last_move_pos {
@@ -849,8 +874,8 @@ impl canvas::Program<Message> for SelectionCanvas {
}
} // end !over_viewcube
// ── UCS icon ──────────────────────────────────────────────────────
if let Some(ref ucs) = self.ucs_icon {
// ── UCS icon (one per Model pane) ─────────────────────────────────
for ucs in &self.ucs_icons {
draw_ucs_icon(
&mut frame,
ucs.view_proj,