diff --git a/assets/icons/ui/grid.svg b/assets/icons/ui/grid.svg new file mode 100644 index 000000000..3bc352bd2 --- /dev/null +++ b/assets/icons/ui/grid.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ui/snap.svg b/assets/icons/ui/snap.svg new file mode 100644 index 000000000..85db41a1b --- /dev/null +++ b/assets/icons/ui/snap.svg @@ -0,0 +1 @@ + diff --git a/src/app/view.rs b/src/app/view.rs index d792efc03..f7ec845c1 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -133,16 +133,32 @@ impl OpenCADStudio { // the correct place and scale. let vp_bounds = tab.scene.active_model_tile_bounds(vw, vh); - let grid = if self.show_grid { + // Model layout: one grid per tile that has grid display on, drawn + // in its own bounds + camera so panes are independent and never + // flicker on hover. Paper layout: a single grid gated by show_grid. + let grid: Vec = if !is_paper { + tab.scene + .model_tile_grid_views(vw, vh) + .into_iter() + .map(|(bounds, cam)| { + let plane = grid_plane_from_camera(cam.pitch, cam.yaw); + overlay::GridParams { + view_proj: cam.view_proj(bounds), + bounds, + plane, + } + }) + .collect() + } else if self.show_grid { let cam = tab.scene.camera.borrow(); let plane = grid_plane_from_camera(cam.pitch, cam.yaw); - Some(overlay::GridParams { + vec![overlay::GridParams { view_proj: cam.view_proj(vp_bounds), bounds: vp_bounds, plane, - }) + }] } else { - None + Vec::new() }; let ucs_icon = if self.show_ucs_icon && !is_paper { @@ -195,12 +211,6 @@ impl OpenCADStudio { ) }; - // Render-mode picker, top-left of the viewport. Drives whether - // 3D face / mesh fills and edges are rendered for the active - // tab. Hatch fills are deliberately *not* gated by this — the - // document's FILLMODE still owns 2D fill state. - let info = render_mode_picker(tab.render_mode); - let viewport_mouse = mouse_area(container( iced::widget::Space::new().width(Fill).height(Fill), )) @@ -365,61 +375,14 @@ impl OpenCADStudio { // viewport drawing / selection is unaffected. In a paper layout // the active viewport gets its own picker (below) instead. if !is_paper && !tab.is_start { - // Render-mode picker + viewport-split buttons (horizontal / - // vertical divider of the active Model tile). - let split_btn = |bytes: &'static [u8], horizontal: bool| { - button(crate::ui::icons::tinted( - bytes, - 14.0, - Color { - r: 0.85, - g: 0.85, - b: 0.85, - a: 1.0, - }, - )) - .on_press(Message::SplitModelViewport(horizontal)) - .padding([4, 8]) - .style(|_: &Theme, status| iced::widget::button::Style { - background: Some(Background::Color(match status { - iced::widget::button::Status::Hovered => Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 0.85, - }, - _ => Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 0.75, - }, - })), - border: Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, - width: 1.0, - radius: 4.0.into(), - }, - text_color: Color { - r: 0.85, - g: 0.85, - b: 0.85, - a: 1.0, - }, - ..Default::default() - }) - }; - let bar = row![ - info, - split_btn(crate::ui::icons::SPLIT_H, true), - split_btn(crate::ui::icons::SPLIT_V, false) - ] - .spacing(4); + // Unified control chip: split buttons + render-mode picker + + // grid / grid-snap toggles, for the active Model tile. + let bar = viewport_controls( + tab.render_mode, + self.show_grid, + self.snapper.is_on(crate::snap::SnapType::Grid), + true, + ); // 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. @@ -490,7 +453,12 @@ impl OpenCADStudio { Space::new().height(iced::Length::Fixed(y + 4.0)), row![ Space::new().width(iced::Length::Fixed(x + 4.0)), - iced::widget::opaque(render_mode_picker(vp_mode)), + iced::widget::opaque(viewport_controls( + vp_mode, + self.show_grid, + self.snapper.is_on(crate::snap::SnapType::Grid), + false, + )), ], ] .width(Fill) @@ -928,7 +896,6 @@ impl OpenCADStudio { self.ortho_mode, self.polar_mode, self.polar_increment_deg, - self.show_grid, self.dyn_input, self.snapper.otrack_enabled, tab.scene.layout_names(), @@ -4208,11 +4175,17 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme // ── Render-mode picker ────────────────────────────────────────────────────── -/// The 7-way visual-style `pick_list`, styled as a dark overlay chip. -/// Shared by the model-space viewport (top-left) and each active -/// paper-space viewport. Emits `SetRenderMode`, which the update loop -/// routes to the active viewport entity or the model-layout tab. -fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> Element<'a, Message> { +/// Top-left viewport control bar: a single dark chip holding (optionally) the +/// horizontal/vertical split buttons, the render-mode picker, and the grid / +/// grid-snap toggles. `include_split` is off for paper-space viewports, which +/// have no model-tile splitting. Grid / snap reflect the active viewport's +/// state and emit `ToggleGrid` / `ToggleGridSnap`. +fn viewport_controls<'a>( + render_mode: acadrust::entities::ViewportRenderMode, + show_grid: bool, + snap_on: bool, + include_split: bool, +) -> Element<'a, Message> { use acadrust::entities::ViewportRenderMode as M; let render_modes: Vec = vec![ RenderModeChoice(M::Wireframe2D), @@ -4223,39 +4196,94 @@ fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> El RenderModeChoice(M::FlatShadedWithEdges), RenderModeChoice(M::GouraudShadedWithEdges), ]; - container( - iced::widget::pick_list(render_modes, Some(RenderModeChoice(current)), |c| { - Message::SetRenderMode(c.0) - }) - .text_size(11), + let light = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; + let accent = Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }; + + // Borderless icon button; an `active` toggle gets an accent tint + fill. + let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message| { + let tint = if active { accent } else { light }; + button(crate::ui::icons::tinted(bytes, 15.0, tint)) + .on_press(msg) + .padding([4, 6]) + .style(move |_: &Theme, status| iced::widget::button::Style { + background: Some(Background::Color(match (active, status) { + (_, iced::widget::button::Status::Hovered) => Color { + r: 0.25, + g: 0.25, + b: 0.25, + a: 0.9, + }, + (true, _) => Color { + r: 0.16, + g: 0.22, + b: 0.32, + a: 0.9, + }, + (false, _) => Color::TRANSPARENT, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + text_color: tint, + ..Default::default() + }) + }; + + // Render-mode picker, restyled borderless so the outer chip frames it. + let picker = iced::widget::pick_list( + render_modes, + Some(RenderModeChoice(render_mode)), + |c| Message::SetRenderMode(c.0), ) - .style(|_: &Theme| iced::widget::container::Style { - background: Some(iced::Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 0.75, - })), - border: iced::Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, - width: 1.0, - radius: 4.0.into(), + .text_size(11) + .padding([4, 6]) + .style(move |_: &Theme, _| iced::widget::pick_list::Style { + background: Background::Color(Color::TRANSPARENT), + border: Border { + radius: 3.0.into(), + ..Default::default() }, - text_color: Some(Color { - r: 0.85, - g: 0.85, - b: 0.85, - a: 1.0, - }), - ..Default::default() - }) - .padding([4, 8]) - .into() + text_color: light, + placeholder_color: light, + handle_color: light, + }); + + let mut bar = row![] + .spacing(2) + .align_y(iced::alignment::Vertical::Center); + if include_split { + bar = bar + .push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true))) + .push(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false))); + } + bar = bar + .push(picker) + .push(icon_btn(crate::ui::icons::GRID, show_grid, Message::ToggleGrid)) + .push(icon_btn(crate::ui::icons::SNAP, snap_on, Message::ToggleGridSnap)); + + container(bar) + .padding(2) + .style(|_: &Theme| iced::widget::container::Style { + background: Some(Background::Color(Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 0.75, + })), + border: Border { + color: Color { + r: 0.35, + g: 0.35, + b: 0.35, + a: 1.0, + }, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() } // ── Dynamic-input field formatting ───────────────────────────────────────── diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 69e65ac02..14e86a5f0 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -6446,6 +6446,35 @@ impl Scene { /// `(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. + /// Canvas bounds + camera for every Model tile whose grid display is on. + /// Each pane renders its own grid independently of which tile is active or + /// hovered, so the grid never flickers as the cursor crosses panes. The + /// active tile uses the live camera (mid-orbit/pan); others use their + /// stored camera. (#121) + pub fn model_tile_grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera)> { + if self.current_layout != "Model" { + return Vec::new(); + } + let tiles = self.model_tiles.borrow(); + let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); + let live = self.camera.borrow().clone(); + tiles + .iter() + .enumerate() + .filter(|(_, t)| t.grid_on) + .map(|(idx, t)| { + let bounds = 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), + }; + let cam = if idx == active { live.clone() } else { t.camera.clone() }; + (bounds, cam) + }) + .collect() + } + 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 }; diff --git a/src/ui/icons.rs b/src/ui/icons.rs index fe57bb2ad..45a6f3fc9 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -33,6 +33,8 @@ pub const BOLT: &[u8] = include_bytes!("../../assets/icons/ui/bolt.svg"); pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg"); pub const SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); +pub const GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); +pub const SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); pub const UP: &[u8] = include_bytes!("../../assets/icons/ui/up.svg"); pub const DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); pub const DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg"); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index dce1f52c4..079363eed 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -107,7 +107,7 @@ pub fn selection_overlay<'a>( selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, - grid: Option, + grid: Vec, ucs_icon: Option, ost_points: Vec, cursor_screen: Point, @@ -134,7 +134,7 @@ struct SelectionCanvas { selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, - grid: Option, + grid: Vec, ucs_icon: Option, ost_points: Vec, cursor_screen: Point, @@ -284,13 +284,11 @@ impl canvas::Program for SelectionCanvas { } // ── Grid display ────────────────────────────────────────────────── - if let Some(ref g) = self.grid { - // 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; + // One entry per pane with its grid on. Each is clipped to its own tile + // rectangle so lines never spill into neighbouring panes. + for g in &self.grid { let gb = g.bounds; - frame.with_clip(gb, |f| draw_grid(f, view_proj, plane, gb)); + frame.with_clip(gb, |f| draw_grid(f, g.view_proj, g.plane, gb)); } if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) { diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 378fb3c2c..8ce4fed36 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -32,7 +32,6 @@ impl StatusBar { ortho_mode: bool, polar_mode: bool, polar_increment_deg: f32, - show_grid: bool, dyn_input: bool, otrack: bool, layouts: Vec, @@ -94,7 +93,6 @@ impl StatusBar { // ── Right side ──────────────────────────────────────────────────── let osnap_active = snapper.is_active(); - let snap_grid_on = snapper.is_on(crate::snap::SnapType::Grid) && snapper.snap_enabled; let vp_label = if viewport_count > 0 { format!("{} VP", viewport_count) @@ -126,18 +124,6 @@ impl StatusBar { "Cursor coordinates (X, Y, Z)", )); } - if vis(StatusPill::Snap) { - right_status = right_status.push(tip( - toggle_pill("SNAP", snap_grid_on, Message::ToggleGridSnap), - "Snap to Grid\nF9", - )); - } - if vis(StatusPill::Grid) { - right_status = right_status.push(tip( - toggle_pill("GRID", show_grid, Message::ToggleGrid), - "Show Grid\nF7", - )); - } if vis(StatusPill::Ortho) { right_status = right_status.push(tip( toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho), diff --git a/src/ui/statusbar_config.rs b/src/ui/statusbar_config.rs index 5a72655d1..2e6e3979f 100644 --- a/src/ui/statusbar_config.rs +++ b/src/ui/statusbar_config.rs @@ -12,8 +12,6 @@ use std::path::PathBuf; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum StatusPill { Coords, - Snap, - Grid, Ortho, Lwt, Polar, @@ -37,8 +35,6 @@ impl StatusPill { /// the customization menu. pub const ALL: &'static [StatusPill] = &[ StatusPill::Coords, - StatusPill::Snap, - StatusPill::Grid, StatusPill::Ortho, StatusPill::Lwt, StatusPill::Polar, @@ -61,8 +57,6 @@ impl StatusPill { pub fn id(self) -> &'static str { match self { StatusPill::Coords => "coords", - StatusPill::Snap => "snap", - StatusPill::Grid => "grid", StatusPill::Ortho => "ortho", StatusPill::Lwt => "lwt", StatusPill::Polar => "polar", @@ -86,8 +80,6 @@ impl StatusPill { pub fn label(self) -> &'static str { match self { StatusPill::Coords => "Coordinates", - StatusPill::Snap => "Snap Mode", - StatusPill::Grid => "Grid", StatusPill::Ortho => "Ortho Mode", StatusPill::Lwt => "Show Lineweight", StatusPill::Polar => "Polar Tracking",