feat(ui): unified viewport control chip + per-tile grid rendering

Move grid and grid-snap toggles off the status bar (and its customization
menu) into the top-left viewport chip, now a single container holding the
horizontal/vertical split buttons, render-mode picker, and grid/snap
toggles, sized to the render-mode picker. Add grid + magnet (snap) icons.

Render the grid per model tile from each tile's own grid_on, in its own
bounds and camera, instead of a single grid in the active tile. This stops
the grid flickering on/off as the cursor crosses panes or leaves the
viewport, and lets each pane show its own grid independently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-18 23:29:34 +03:00
commit 9eb805df23
8 changed files with 170 additions and 133 deletions

1
assets/icons/ui/grid.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.8" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1.5"/><path d="M4 9.3 H20 M4 14.7 H20 M9.3 4 V20 M14.7 4 V20" stroke-width="1.4"/></svg>

After

Width:  |  Height:  |  Size: 266 B

1
assets/icons/ui/snap.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5 V11 a6 6 0 0 0 12 0 V5"/><path d="M6 5 H10 M14 5 H18" stroke-width="2.6"/></svg>

After

Width:  |  Height:  |  Size: 250 B

View file

@ -133,16 +133,32 @@ impl OpenCADStudio {
// the correct place and scale. // the correct place and scale.
let vp_bounds = tab.scene.active_model_tile_bounds(vw, vh); 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<overlay::GridParams> = 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 cam = tab.scene.camera.borrow();
let plane = grid_plane_from_camera(cam.pitch, cam.yaw); let plane = grid_plane_from_camera(cam.pitch, cam.yaw);
Some(overlay::GridParams { vec![overlay::GridParams {
view_proj: cam.view_proj(vp_bounds), view_proj: cam.view_proj(vp_bounds),
bounds: vp_bounds, bounds: vp_bounds,
plane, plane,
}) }]
} else { } else {
None Vec::new()
}; };
let ucs_icon = if self.show_ucs_icon && !is_paper { 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( let viewport_mouse = mouse_area(container(
iced::widget::Space::new().width(Fill).height(Fill), iced::widget::Space::new().width(Fill).height(Fill),
)) ))
@ -365,61 +375,14 @@ impl OpenCADStudio {
// viewport drawing / selection is unaffected. In a paper layout // viewport drawing / selection is unaffected. In a paper layout
// the active viewport gets its own picker (below) instead. // the active viewport gets its own picker (below) instead.
if !is_paper && !tab.is_start { if !is_paper && !tab.is_start {
// Render-mode picker + viewport-split buttons (horizontal / // Unified control chip: split buttons + render-mode picker +
// vertical divider of the active Model tile). // grid / grid-snap toggles, for the active Model tile.
let split_btn = |bytes: &'static [u8], horizontal: bool| { let bar = viewport_controls(
button(crate::ui::icons::tinted( tab.render_mode,
bytes, self.show_grid,
14.0, self.snapper.is_on(crate::snap::SnapType::Grid),
Color { true,
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);
// Position the bar at the active model tile's top-left corner so // 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 // it follows the active panel in a tiled layout (full canvas when
// a single tile fills the window). Leading Spaces offset it. // 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)), Space::new().height(iced::Length::Fixed(y + 4.0)),
row![ row![
Space::new().width(iced::Length::Fixed(x + 4.0)), 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) .width(Fill)
@ -928,7 +896,6 @@ impl OpenCADStudio {
self.ortho_mode, self.ortho_mode,
self.polar_mode, self.polar_mode,
self.polar_increment_deg, self.polar_increment_deg,
self.show_grid,
self.dyn_input, self.dyn_input,
self.snapper.otrack_enabled, self.snapper.otrack_enabled,
tab.scene.layout_names(), tab.scene.layout_names(),
@ -4208,11 +4175,17 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme
// ── Render-mode picker ────────────────────────────────────────────────────── // ── Render-mode picker ──────────────────────────────────────────────────────
/// The 7-way visual-style `pick_list`, styled as a dark overlay chip. /// Top-left viewport control bar: a single dark chip holding (optionally) the
/// Shared by the model-space viewport (top-left) and each active /// horizontal/vertical split buttons, the render-mode picker, and the grid /
/// paper-space viewport. Emits `SetRenderMode`, which the update loop /// grid-snap toggles. `include_split` is off for paper-space viewports, which
/// routes to the active viewport entity or the model-layout tab. /// have no model-tile splitting. Grid / snap reflect the active viewport's
fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> Element<'a, Message> { /// 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; use acadrust::entities::ViewportRenderMode as M;
let render_modes: Vec<RenderModeChoice> = vec![ let render_modes: Vec<RenderModeChoice> = vec![
RenderModeChoice(M::Wireframe2D), RenderModeChoice(M::Wireframe2D),
@ -4223,39 +4196,94 @@ fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> El
RenderModeChoice(M::FlatShadedWithEdges), RenderModeChoice(M::FlatShadedWithEdges),
RenderModeChoice(M::GouraudShadedWithEdges), RenderModeChoice(M::GouraudShadedWithEdges),
]; ];
container( let light = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 };
iced::widget::pick_list(render_modes, Some(RenderModeChoice(current)), |c| { let accent = Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 };
Message::SetRenderMode(c.0)
}) // Borderless icon button; an `active` toggle gets an accent tint + fill.
.text_size(11), 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 { .text_size(11)
background: Some(iced::Background::Color(Color { .padding([4, 6])
r: 0.10, .style(move |_: &Theme, _| iced::widget::pick_list::Style {
g: 0.10, background: Background::Color(Color::TRANSPARENT),
b: 0.10, border: Border {
a: 0.75, radius: 3.0.into(),
})), ..Default::default()
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_color: Some(Color { text_color: light,
r: 0.85, placeholder_color: light,
g: 0.85, handle_color: light,
b: 0.85, });
a: 1.0,
}), let mut bar = row![]
..Default::default() .spacing(2)
}) .align_y(iced::alignment::Vertical::Center);
.padding([4, 8]) if include_split {
.into() 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 ───────────────────────────────────────── // ── Dynamic-input field formatting ─────────────────────────────────────────

View file

@ -6446,6 +6446,35 @@ impl Scene {
/// `(vw, vh)`. Full canvas outside the Model layout or for a single /// `(vw, vh)`. Full canvas outside the Model layout or for a single
/// tile. Used to map cursor coordinates into the active tile so pick / /// tile. Used to map cursor coordinates into the active tile so pick /
/// pan / ViewCube work per-pane in a tiled layout. /// 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 { pub fn active_model_tile_bounds(&self, vw: f32, vh: f32) -> iced::Rectangle {
if self.current_layout != "Model" { if self.current_layout != "Model" {
return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; return iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh };

View file

@ -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 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_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 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 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_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg");
pub const DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg"); pub const DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg");

View file

@ -107,7 +107,7 @@ pub fn selection_overlay<'a>(
selection: SelectionState, selection: SelectionState,
snap: Option<(Point, SnapType)>, snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>, grips: Vec<GripMarker>,
grid: Option<GridParams>, grid: Vec<GridParams>,
ucs_icon: Option<UcsIconParams>, ucs_icon: Option<UcsIconParams>,
ost_points: Vec<OstTrackPoint>, ost_points: Vec<OstTrackPoint>,
cursor_screen: Point, cursor_screen: Point,
@ -134,7 +134,7 @@ struct SelectionCanvas {
selection: SelectionState, selection: SelectionState,
snap: Option<(Point, SnapType)>, snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>, grips: Vec<GripMarker>,
grid: Option<GridParams>, grid: Vec<GridParams>,
ucs_icon: Option<UcsIconParams>, ucs_icon: Option<UcsIconParams>,
ost_points: Vec<OstTrackPoint>, ost_points: Vec<OstTrackPoint>,
cursor_screen: Point, cursor_screen: Point,
@ -284,13 +284,11 @@ impl canvas::Program<Message> for SelectionCanvas {
} }
// ── Grid display ────────────────────────────────────────────────── // ── Grid display ──────────────────────────────────────────────────
if let Some(ref g) = self.grid { // One entry per pane with its grid on. Each is clipped to its own tile
// Clip to the active tile's rectangle so grid lines don't spill // rectangle so lines never spill into neighbouring panes.
// into neighbouring panes in a tiled layout. for g in &self.grid {
let plane = g.plane;
let view_proj = g.view_proj;
let gb = g.bounds; 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) { if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) {

View file

@ -32,7 +32,6 @@ impl StatusBar {
ortho_mode: bool, ortho_mode: bool,
polar_mode: bool, polar_mode: bool,
polar_increment_deg: f32, polar_increment_deg: f32,
show_grid: bool,
dyn_input: bool, dyn_input: bool,
otrack: bool, otrack: bool,
layouts: Vec<String>, layouts: Vec<String>,
@ -94,7 +93,6 @@ impl StatusBar {
// ── Right side ──────────────────────────────────────────────────── // ── Right side ────────────────────────────────────────────────────
let osnap_active = snapper.is_active(); 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 { let vp_label = if viewport_count > 0 {
format!("{} VP", viewport_count) format!("{} VP", viewport_count)
@ -126,18 +124,6 @@ impl StatusBar {
"Cursor coordinates (X, Y, Z)", "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) { if vis(StatusPill::Ortho) {
right_status = right_status.push(tip( right_status = right_status.push(tip(
toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho), toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho),

View file

@ -12,8 +12,6 @@ use std::path::PathBuf;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum StatusPill { pub enum StatusPill {
Coords, Coords,
Snap,
Grid,
Ortho, Ortho,
Lwt, Lwt,
Polar, Polar,
@ -37,8 +35,6 @@ impl StatusPill {
/// the customization menu. /// the customization menu.
pub const ALL: &'static [StatusPill] = &[ pub const ALL: &'static [StatusPill] = &[
StatusPill::Coords, StatusPill::Coords,
StatusPill::Snap,
StatusPill::Grid,
StatusPill::Ortho, StatusPill::Ortho,
StatusPill::Lwt, StatusPill::Lwt,
StatusPill::Polar, StatusPill::Polar,
@ -61,8 +57,6 @@ impl StatusPill {
pub fn id(self) -> &'static str { pub fn id(self) -> &'static str {
match self { match self {
StatusPill::Coords => "coords", StatusPill::Coords => "coords",
StatusPill::Snap => "snap",
StatusPill::Grid => "grid",
StatusPill::Ortho => "ortho", StatusPill::Ortho => "ortho",
StatusPill::Lwt => "lwt", StatusPill::Lwt => "lwt",
StatusPill::Polar => "polar", StatusPill::Polar => "polar",
@ -86,8 +80,6 @@ impl StatusPill {
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
StatusPill::Coords => "Coordinates", StatusPill::Coords => "Coordinates",
StatusPill::Snap => "Snap Mode",
StatusPill::Grid => "Grid",
StatusPill::Ortho => "Ortho Mode", StatusPill::Ortho => "Ortho Mode",
StatusPill::Lwt => "Show Lineweight", StatusPill::Lwt => "Show Lineweight",
StatusPill::Polar => "Polar Tracking", StatusPill::Polar => "Polar Tracking",