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

+205 -26
View File
@@ -6,7 +6,7 @@ use super::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::grip::{find_hit_grip, find_hit_grip_paper, GripEdit};
use crate::scene::object::GripApply;
use crate::scene::{self, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX};
use crate::scene::{self, hover_id, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX};
use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor;
use acadrust::{EntityType as AcadEntityType, Handle};
@@ -1592,25 +1592,84 @@ impl OpenCADStudio {
// the full canvas in model space, or of the active
// viewport's screen rectangle in a paper layout.
let i = self.active_tab;
let (vw, _vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (ox, oy) = match self
.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, _vh)))
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, vh)))
{
Some(rect) => (
rect.x + rect.width - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE,
rect.y + VIEWCUBE_PAD,
),
None => (vw - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE, VIEWCUBE_PAD),
None => {
// Model layout: the cube sits in the active tile's
// top-right corner.
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(
tb.x + tb.width - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE,
tb.y + VIEWCUBE_PAD,
)
}
};
self.cursor_pos = iced::Point::new(ox + p.x, oy + p.y);
// Drive the ViewCube hover highlight directly from this
// message — it fires whenever the cube's hit-area overlay
// sees motion, so we don't depend on the shader widget's
// `Program::update` receiving the same event (overlays sit
// above the shader and can mask it). Map the cursor into
// the active viewport's local box and use that box's size,
// since that's where the cube is actually drawn.
let tile = match self
.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, vh)))
{
Some(rect) => rect,
None => self.tabs[i].scene.active_model_tile_bounds(vw, vh),
};
let cam_rot = self.tabs[i].scene.camera.borrow().view_rotation_mat();
let hover = hover_id(
self.cursor_pos.x - tile.x,
self.cursor_pos.y - tile.y,
tile.width,
tile.height,
cam_rot,
VIEWCUBE_PX,
);
self.tabs[i].scene.viewcube_hover.set(hover);
Task::none()
}
Message::ViewportMove(p) => {
let i = self.active_tab;
// Keep the ViewCube hover in sync as the cursor leaves the
// hit-area overlay and moves over the rest of the viewport.
// `hover_id` returns None outside the cube box, which clears
// any stale highlight from the previous `CursorMoved`.
let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size;
let cube_tile = match self
.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (svw, svh)))
{
Some(rect) => rect,
None => self.tabs[i].scene.active_model_tile_bounds(svw, svh),
};
let cam_rot = self.tabs[i].scene.camera.borrow().view_rotation_mat();
self.tabs[i].scene.viewcube_hover.set(hover_id(
p.x - cube_tile.x,
p.y - cube_tile.y,
cube_tile.width,
cube_tile.height,
cam_rot,
VIEWCUBE_PX,
));
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.last_move_pos = Some(p);
@@ -1682,18 +1741,25 @@ impl OpenCADStudio {
if mid_down {
if let Some(last) = mid_last {
let (dx, dy) = (p.x - last.x, p.y - last.y);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
// Pan scale uses the active tile's size (ortho size
// is relative to viewport height), so a tiled pane
// pans at the correct rate.
let bounds = self
.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
// Drop `sel` before calling mutable scene methods.
drop(sel);
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, dy, bounds);
} else {
self.tabs[i].scene.camera.borrow_mut().pan(dx, dy);
// `bounds` is the active tile; pan by its height so
// the point under the cursor tracks correctly.
self.tabs[i]
.scene
.camera
.borrow_mut()
.pan_screen(dx, dy, bounds.height);
self.tabs[i].scene.camera_generation += 1;
}
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
@@ -1702,9 +1768,40 @@ impl OpenCADStudio {
sel.middle_last_pos = Some(p);
}
let dragging = sel.left_down || sel.right_down || sel.middle_down;
let vp_size = sel.vp_size;
drop(sel);
// Hover (no button held): the tile under the cursor becomes
// active, so the camera + tile bounds used for picking below
// follow the pane the cursor is in. During a drag the active
// tile stays put so the operation finishes in its own pane.
if !dragging && vp_size.0 > 1.0 && vp_size.1 > 1.0 {
if self
.tabs[i]
.scene
.set_active_model_tile_at(p.x / vp_size.0, p.y / vp_size.1)
{
self.tabs[i].scene.camera_generation += 1;
}
}
// Tile-relative picking: shadow `p` with the cursor mapped
// into the active Model tile and `vp_size` with the tile's
// size, so every pick / snap / view_proj below operates in
// the active pane. `p_full` keeps the canvas-space cursor
// for screen overlays (cursor marker, snap glyph).
let p_full = p;
let tile_b = self
.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
let p = iced::Point {
x: p_full.x - tile_b.x,
y: p_full.y - tile_b.y,
};
let vp_size = (tile_b.width, tile_b.height);
// ── Grip drag ─────────────────────────────────────────────
if let Some(grip) = self.tabs[i].active_grip.clone() {
let (vw, vh) = vp_size;
@@ -1730,6 +1827,10 @@ impl OpenCADStudio {
let snap_hit = self.snapper.snap(raw, p, &snap_wires, vp_mat, bounds);
let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
self.tabs[i].snap_result = snap_hit;
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
}
if snap_hit.is_none() {
let base = grip.origin_world;
@@ -1870,7 +1971,13 @@ impl OpenCADStudio {
pt
};
self.tabs[i].last_cursor_world = effective;
self.tabs[i].last_cursor_screen = p;
self.tabs[i].last_cursor_screen = p_full;
// Snap glyph is positioned in canvas space; shift the
// tile-local snap screen point back to the full canvas.
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
}
let mut previews = if needs_entity {
let hover_handle =
@@ -1967,12 +2074,6 @@ impl OpenCADStudio {
(p, sel.vp_size)
};
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if vw > 1.0 && vh > 1.0 {
let cam = self.tabs[i].scene.camera.borrow();
@@ -1996,6 +2097,23 @@ impl OpenCADStudio {
}
}
// From here the click targets the active tile: map the
// cursor into it and use the tile's size for picking, so
// grip / selection hit-tests land in the right pane.
let p_full = p;
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
let p = iced::Point {
x: p_full.x - tile_b.x,
y: p_full.y - tile_b.y,
};
let (vw, vh) = (tile_b.width, tile_b.height);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if self.tabs[i].active_cmd.is_none() && !self.tabs[i].selected_grips.is_empty() {
if let Some(handle) = self.tabs[i].selected_handle {
let is_paper = self.tabs[i].scene.current_layout != "Model";
@@ -2036,7 +2154,10 @@ impl OpenCADStudio {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.context_menu = None;
sel.left_down = true;
sel.left_press_pos = Some(p);
// Stored in full-canvas space (like ViewportMove's cursor and
// the overlay box / lasso drawing); release maps it into the
// active tile. Tile-local here would double-offset the anchor.
sel.left_press_pos = Some(p_full);
sel.left_press_time = Some(Instant::now());
sel.left_dragging = false;
Task::none()
@@ -2059,6 +2180,21 @@ impl OpenCADStudio {
return Task::none();
}
// Map the release point into the active Model tile so the
// click's pick / on_point / selection use the active pane's
// camera + bounds. `p_full` keeps the canvas point for the
// box/poly selection rectangle (drawn in canvas space).
let p_full = p;
let (tile_vw, tile_vh, tile_off) = {
let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size;
let tb = self.tabs[i].scene.active_model_tile_bounds(svw, svh);
(tb.width, tb.height, iced::Point::new(tb.x, tb.y))
};
let p = iced::Point {
x: p_full.x - tile_off.x,
y: p_full.y - tile_off.y,
};
let is_gathering = self.tabs[i]
.active_cmd
.as_ref()
@@ -2066,7 +2202,7 @@ impl OpenCADStudio {
.unwrap_or(false);
if is_down && is_click && self.tabs[i].active_cmd.is_some() && !is_gathering {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (vw, vh) = (tile_vw, tile_vh);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
@@ -2252,7 +2388,7 @@ impl OpenCADStudio {
return Task::none();
}
let (is_down2, is_dragging, box_anchor, box_crossing, vp_size, elapsed_ms) = {
let (is_down2, is_dragging, box_anchor, box_crossing, _vp_size, elapsed_ms) = {
let sel = self.tabs[i].scene.selection.borrow();
let elapsed = sel
.left_press_time
@@ -2270,6 +2406,15 @@ impl OpenCADStudio {
let mut selection_just_completed = false;
// Active-tile-local selection: tile-sized bounds and the box
// anchor mapped into the tile, so box / crossing selection
// matches the active pane (p is already tile-local).
let vp_size = (tile_vw, tile_vh);
let box_anchor = box_anchor.map(|a| iced::Point {
x: a.x - tile_off.x,
y: a.y - tile_off.y,
});
if is_down2 {
let bounds = iced::Rectangle {
x: 0.0,
@@ -2314,7 +2459,16 @@ impl OpenCADStudio {
} else {
let (poly_pts, crossing) = {
let sel = self.tabs[i].scene.selection.borrow();
(sel.poly_points.clone(), sel.poly_crossing)
// Map lasso points into the active tile.
let pts: Vec<iced::Point> = sel
.poly_points
.iter()
.map(|pp| iced::Point {
x: pp.x - tile_off.x,
y: pp.y - tile_off.y,
})
.collect();
(pts, sel.poly_crossing)
};
self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing;
let all_wires = self.tabs[i].scene.hit_test_wires();
@@ -2383,8 +2537,11 @@ impl OpenCADStudio {
self.tabs[i].scene.deselect_all();
self.refresh_properties();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.box_anchor = Some(p);
sel.box_current = Some(p);
// Full-canvas space: ViewportMove updates
// box_current in canvas coords and the overlay
// draws there; release maps back into the tile.
sel.box_anchor = Some(p_full);
sel.box_current = Some(p_full);
sel.box_crossing = false;
}
} else {
@@ -2665,9 +2822,22 @@ impl OpenCADStudio {
});
self.tabs[i].scene.zoom_active_viewport(s, cursor_paper);
} else {
// Model space: zoom about the cursor within the active
// tile so the point under it stays put in that pane.
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
let mut cam = self.tabs[i].scene.camera.borrow_mut();
if let Some(cursor) = cursor {
cam.zoom_about_point(cursor, bounds, s);
let local = iced::Point {
x: cursor.x - tile_b.x,
y: cursor.y - tile_b.y,
};
let tb = iced::Rectangle {
x: 0.0,
y: 0.0,
width: tile_b.width,
height: tile_b.height,
};
cam.zoom_about_point(local, tb, s);
} else {
cam.zoom(s);
}
@@ -2698,7 +2868,16 @@ impl OpenCADStudio {
rect.width,
rect.height,
),
None => (self.cursor_pos.x, self.cursor_pos.y, vw, vh),
None => {
// Model layout: hit-test within the active tile.
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(
self.cursor_pos.x - tb.x,
self.cursor_pos.y - tb.y,
tb.width,
tb.height,
)
}
};
if let Some(region) = scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX) {
return Task::done(Message::ViewCubeSnap(region));
+45 -38
View File
@@ -275,12 +275,11 @@ impl OpenCADStudio {
let grips: Vec<overlay::GripMarker> =
if tab.active_cmd.is_none() && !tab.selected_grips.is_empty() {
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
// Overlays project through the active tile's camera, so
// they must use the active tile's screen rectangle (with
// its canvas offset) — not the whole canvas — or they
// land in the wrong place in a tiled layout.
let bounds = tab.scene.active_model_tile_bounds(vw, vh);
let sel_h = tab.selected_handle;
let screen_grips = if is_paper {
let cam = tab.scene.camera.borrow();
@@ -322,12 +321,10 @@ impl OpenCADStudio {
};
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let vp_bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
// Active tile rectangle (canvas-offset included) so grid / UCS
// icon / crosshair project through the active pane's camera at
// the correct place and scale.
let vp_bounds = tab.scene.active_model_tile_bounds(vw, vh);
let grid = if self.show_grid {
let cam = tab.scene.camera.borrow();
@@ -529,13 +526,21 @@ impl OpenCADStudio {
})
};
let bar = row![info, split_btn("▤", true), split_btn("▥", false)].spacing(4);
viewport_stack = viewport_stack.push(
container(iced::widget::opaque(bar))
.width(Fill)
.height(Fill)
.align_x(iced::alignment::Horizontal::Left)
.align_y(iced::alignment::Vertical::Top),
);
// Position the bar at the active model tile's top-left corner so
// it follows the active panel in a tiled layout (full canvas when
// a single tile fills the window). Leading Spaces offset it.
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let rect = tab.scene.active_model_tile_bounds(vw, vh);
let bar_layer = column![
Space::new().height(iced::Length::Fixed(rect.y.max(0.0))),
row![
Space::new().width(iced::Length::Fixed(rect.x.max(0.0))),
iced::widget::opaque(bar),
],
]
.width(Fill)
.height(Fill);
viewport_stack = viewport_stack.push(bar_layer);
}
// Active paper-space viewport overlays: a render-mode picker in
@@ -592,26 +597,28 @@ impl OpenCADStudio {
}
if self.show_viewcube && !is_paper && !tab.is_start {
let cube_click: Element<'_, Message> = container(
mouse_area(container(
iced::widget::Space::new()
.width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE))
.height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)),
))
.on_move(Message::CursorMoved)
.on_press(Message::ViewportClick),
)
.align_right(Fill)
.align_top(Fill)
.padding(iced::Padding {
top: VIEWCUBE_PAD,
right: VIEWCUBE_PAD,
bottom: 0.0,
left: 0.0,
})
// Place the ViewCube hit area in the active model tile's top-right
// corner so it tracks the active panel in a tiled layout. The hit
// test in update.rs already maps clicks through the active tile.
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let rect = tab.scene.active_model_tile_bounds(vw, vh);
let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0);
let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0);
let cube_click = column![
Space::new().height(iced::Length::Fixed(cube_y)),
row![
Space::new().width(iced::Length::Fixed(cube_x)),
mouse_area(
iced::widget::Space::new()
.width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE))
.height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)),
)
.on_move(Message::CursorMoved)
.on_press(Message::ViewportClick),
],
]
.width(Fill)
.height(Fill)
.into();
.height(Fill);
viewport_stack = viewport_stack.push(cube_click);
}
+12 -4
View File
@@ -234,12 +234,20 @@ impl Camera {
self.target += before - after;
}
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
let speed = self.distance * 0.001;
/// Pan so the world point under the cursor tracks it: screen pixels are
/// converted to world units via the ortho world-per-pixel scale of a
/// viewport `viewport_height` pixels tall. Used by tiled panes where the
/// pane height differs from the full canvas.
pub fn pan_screen(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
let wpp = if viewport_height > 0.0 {
(2.0 * self.ortho_size()) / viewport_height
} else {
0.0
};
let cam_right = self.rotation * Vec3::X;
let cam_up = self.rotation * Vec3::Y;
self.target -= cam_right * delta_x * speed;
self.target += cam_up * delta_y * speed;
self.target -= cam_right * delta_x * wpp;
self.target += cam_up * delta_y * wpp;
}
pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3) {
+2 -2
View File
@@ -42,8 +42,8 @@ pub fn grips_to_screen(
.map(|g| {
let ndc = view_proj.project_point3(g.world);
let screen = 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,
);
(g.id, screen, g.is_midpoint, g.shape)
})
+31 -1
View File
@@ -40,7 +40,9 @@ pub use image_model::ImageModel;
pub use mesh_model::MeshLodSet;
pub use object::{GripApply, GripDef};
pub use pipeline::uniforms::Uniforms;
pub use pipeline::viewcube::{hit_test, CubeRegion, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX};
pub use pipeline::viewcube::{
hit_test, hover_id, CubeRegion, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
pub use selection::SelectionState;
pub use wire_model::WireModel;
@@ -548,6 +550,12 @@ pub struct Scene {
/// in expand_insert / tessellate_entity. 0 means "not yet set" — culling
/// falls back to None.
last_world_per_pixel: std::cell::Cell<f32>,
/// ViewCube hover region (0..25, face/edge/corner index), driven by the
/// `CursorMoved` message that the cube hit-area overlay publishes. Lives
/// here so the unified render path can read it for the active viewport
/// without depending on the shader widget's internal `Program::State`
/// (which can miss events under overlapping overlays).
pub viewcube_hover: std::cell::Cell<Option<usize>>,
}
impl Scene {
@@ -597,6 +605,7 @@ impl Scene {
entity_index_cache: RefCell::new(None),
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
last_world_per_pixel: std::cell::Cell::new(0.0),
viewcube_hover: std::cell::Cell::new(None),
}
}
@@ -4390,6 +4399,27 @@ impl Scene {
self.active_model_tile.set(0);
}
/// 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 /
/// pan / ViewCube work per-pane in a tiled layout.
pub fn active_model_tile_bounds(&self, vw: f32, vh: f32) -> iced::Rectangle {
if self.current_layout != "Model" {
return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh };
}
let tiles = self.model_tiles.borrow();
let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1));
match tiles.get(active) {
Some(t) => iced::Rectangle {
x: t.rect.x * vw,
y: t.rect.y * vh,
width: (t.rect.width * vw).max(1.0),
height: (t.rect.height * vh).max(1.0),
},
None => iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh },
}
}
/// The viewports to render this frame, one entry per scissor pass.
///
/// - **Model layout**: a single full-canvas instance driven by the
+18 -6
View File
@@ -558,8 +558,12 @@ impl Scene {
&self,
bounds: Rectangle,
model_render_mode: acadrust::entities::ViewportRenderMode,
hover_region: Option<usize>,
_hover_region: Option<usize>,
) -> Primitive {
// Hover comes from the scene cell driven by the app-level
// `CursorMoved` handler — the cube overlay sits above the shader
// and would otherwise mask the move event from `Program::update`.
let hover_region = self.viewcube_hover.get();
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
if bounds.height > 0.0 {
self.set_render_aspect(bounds.width / bounds.height);
@@ -682,10 +686,13 @@ impl Scene {
pub(super) fn build_viewport_primitive(
&self,
vp_handle: Handle,
hover_region: Option<usize>,
_hover_region: Option<usize>,
bounds: Rectangle,
show_viewcube: bool,
) -> Primitive {
// See `build_viewports` — the cube hit-area overlay shadows the
// shader, so hover is sourced from the scene cell.
let hover_region = self.viewcube_hover.get();
let render_mode = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp.render_mode,
_ => acadrust::entities::ViewportRenderMode::Wireframe2D,
@@ -755,6 +762,10 @@ impl Scene {
}
/// Update viewcube hover state from cursor position within `bounds`.
///
/// The cube draws in the top-right of the *active model tile* (which fills
/// the canvas when there is a single tile), so the hover hit-test maps the
/// cursor into that tile's local space and uses the tile's dimensions.
pub(super) fn update_viewcube_state(
&self,
state: &mut CameraState,
@@ -764,11 +775,12 @@ impl Scene {
let pos = cursor.position_in(bounds);
let cam_rotation = self.camera.borrow().view_rotation_mat();
if let Some(p) = pos {
let tile = self.active_model_tile_bounds(bounds.width, bounds.height);
state.hover_region = hover_id(
p.x,
p.y,
bounds.width,
bounds.height,
p.x - tile.x,
p.y - tile.y,
tile.width,
tile.height,
cam_rotation,
VIEWCUBE_PX,
);
+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.