feat(viewport): tile-relative input + overlays in unified model

Route every per-frame interaction through the active model tile's screen
rectangle so a tiled VPORTS layout behaves like independent panes while
still sharing one shader widget:

- pick / draw / snap / box+lasso: cursor mapped to tile-local space; the
  hover-activates-tile rule on ViewportMove keeps the active tile under
  the cursor, so the picked camera matches what the user sees.
- pan: switched to Camera::pan_screen(dx, dy, viewport_height) so the
  world-per-pixel rate matches the active tile's height instead of the
  full canvas.
- grid: clipped via Frame::with_clip to the active tile rect; world→screen
  adds the tile origin so the lines align with the panel.
- UCS icon: origin offset by the tile rect so the gizmo sits in the
  active tile's bottom-left corner.
- render-mode picker (+ split buttons) and ViewCube hit-area: positioned
  via active_model_tile_bounds with leading Spaces, mirroring the
  floating-viewport pattern.
- ViewCube hover: driven by the CursorMoved/ViewportMove handlers into a
  new Scene::viewcube_hover cell, so the highlight survives the cube
  hit-area overlay masking events from shader::Program::update.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-28 09:45:19 +03:00
1 parent 6fe25e4b5f
commit 417a708275
7 files changed
+330 -86

No files matched your search

+17 -9
View File
@@ -177,7 +177,12 @@ impl canvas::Program<Message> for SelectionCanvas {
// ── Grid display ──────────────────────────────────────────────────
if let Some(ref g) = self.grid {
draw_grid(&mut frame, g.view_proj, g.plane, g.bounds);
// Clip to the active tile's rectangle so grid lines don't spill
// into neighbouring panes in a tiled layout.
let plane = g.plane;
let view_proj = g.view_proj;
let gb = g.bounds;
frame.with_clip(gb, |f| draw_grid(f, view_proj, plane, gb));
}
if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) {
@@ -551,11 +556,14 @@ impl canvas::Program<Message> for SelectionCanvas {
const MIN_GRID_PX: f32 = 20.0;
fn draw_grid(frame: &mut canvas::Frame, vp: Mat4, plane: GridPlane, bounds: iced::Rectangle) {
// World → canvas screen: include bounds origin so the grid lands in the
// active tile's rectangle (the screen → world unproject below stays
// tile-local, which is what feeds the visible-extent computation).
let w2s = |world: Vec3| -> Point {
let ndc = vp.project_point3(world);
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
};
@@ -668,8 +676,8 @@ fn draw_axes(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle, exten
let w2s = |world: Vec3| -> Point {
let ndc = vp.project_point3(world);
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
};
let e = extent;
@@ -731,8 +739,8 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) {
};
let ndc2s = |ndc: Vec3| -> Point {
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
};
@@ -743,8 +751,8 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) {
let org_s = ndc2s(org);
let icon_origin = Point::new(
UCS_ICON_MARGIN,
(bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
bounds.x + UCS_ICON_MARGIN,
bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
);
// Raw screen-space displacement for each axis tip.