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",