diff --git a/src/app/update.rs b/src/app/update.rs index 45201a0e6..3b9c87300 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 = 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)); diff --git a/src/app/view.rs b/src/app/view.rs index 1e7ebb76b..58848b1d7 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -275,12 +275,11 @@ impl OpenCADStudio { let grips: Vec = 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); } diff --git a/src/scene/camera.rs b/src/scene/camera.rs index 8ae06c660..8d883adb4 100644 --- a/src/scene/camera.rs +++ b/src/scene/camera.rs @@ -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) { diff --git a/src/scene/grip.rs b/src/scene/grip.rs index 6be197b8d..f62fdde8d 100644 --- a/src/scene/grip.rs +++ b/src/scene/grip.rs @@ -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) }) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 017ebd4f0..48335c88c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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, + /// 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>, } 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 diff --git a/src/scene/render.rs b/src/scene/render.rs index 766aa3d1c..eaa7ff9cb 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -558,8 +558,12 @@ impl Scene { &self, bounds: Rectangle, model_render_mode: acadrust::entities::ViewportRenderMode, - hover_region: Option, + _hover_region: Option, ) -> 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, + _hover_region: Option, 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, ); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index c80a7f32c..d19df5898 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -177,7 +177,12 @@ impl canvas::Program 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 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.