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 SevenandClaude Opus 4.8 committed 2026-06-18 23:29:34 +03:00
1 parent d4a665a34c
commit 9eb805df23
8 files changed
+170 -133

No files matched your search

+1
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
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

+131 -103
View File
@@ -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<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 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<RenderModeChoice> = 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 ─────────────────────────────────────────
+29
View File
@@ -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 };
+2
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 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");
+6 -8
View File
@@ -107,7 +107,7 @@ pub fn selection_overlay<'a>(
selection: SelectionState,
snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>,
grid: Option<GridParams>,
grid: Vec<GridParams>,
ucs_icon: Option<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
cursor_screen: Point,
@@ -134,7 +134,7 @@ struct SelectionCanvas {
selection: SelectionState,
snap: Option<(Point, SnapType)>,
grips: Vec<GripMarker>,
grid: Option<GridParams>,
grid: Vec<GridParams>,
ucs_icon: Option<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
cursor_screen: Point,
@@ -284,13 +284,11 @@ impl canvas::Program<Message> 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) {
-14
View File
@@ -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<String>,
@@ -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),
-8
View File
@@ -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",