forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
d4a665a34c
commit
9eb805df23
8 files changed
+170
-133
No files matched your search
@@ -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 |
@@ -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
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user