feat(view): add visual style previews

- preview render modes on hover without committing
- distinguish 2D and 3D wireframe fill and depth behavior

Refs #483
This commit is contained in:
Hakan Seven committed 2026-08-04 21:23:25 +03:00
1 parent 44d0c65ae9
commit b1d55c6a7e
49 files changed
+560 -51

No files matched your search

+13
View File
@@ -498,6 +498,11 @@ pub(super) struct OpenCADStudio {
/// next frame to decide whether the ViewCube still has room beside it — so
/// the two corner widgets adapt to the bar's real width, not an estimate.
render_bar_w: std::sync::Arc<std::sync::atomic::AtomicU32>,
/// Whether the visual-style flyout beside the active viewport is open.
render_mode_menu_open: bool,
/// Mode whose sample is shown while the pointer moves through the flyout.
/// This does not alter the drawing until the corresponding row is clicked.
render_mode_preview: Option<acadrust::entities::ViewportRenderMode>,
/// Whether the Properties panel is shown on the left (PROPERTIES).
show_properties: bool,
/// Whether the document file tabs are shown at the top (FILETAB).
@@ -1708,6 +1713,12 @@ pub enum Message {
/// styles). Replaces the binary `SetWireframe` over time; the older
/// message stays for ribbon/CLI back-compat and forwards.
SetRenderMode(acadrust::entities::ViewportRenderMode),
/// Open or close the active viewport's visual-style flyout.
ToggleRenderModeMenu(acadrust::entities::ViewportRenderMode),
/// Close the visual-style flyout after Escape or an outside click.
DismissRenderModeMenu,
/// Change only the sample shown beside the visual-style list.
PreviewRenderMode(acadrust::entities::ViewportRenderMode),
/// Switch camera projection: true = Orthographic, false = Perspective.
SetProjection(bool),
/// Select a ribbon module tab by index.
@@ -2933,6 +2944,8 @@ impl OpenCADStudio {
ucs_icon_at_origin: true,
show_viewcube: true,
render_bar_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
render_mode_menu_open: false,
render_mode_preview: None,
show_properties: true,
show_file_tabs: true,
show_layout_tabs: true,
+24 -1
View File
@@ -1191,7 +1191,30 @@ impl OpenCADStudio {
Task::done(Message::SetRenderMode(mode))
}
Message::SetRenderMode(mode) => self.on_set_render_mode(mode),
Message::SetRenderMode(mode) => {
self.render_mode_menu_open = false;
self.render_mode_preview = None;
self.on_set_render_mode(mode)
}
Message::ToggleRenderModeMenu(mode) => {
self.render_mode_menu_open = !self.render_mode_menu_open;
self.render_mode_preview = self.render_mode_menu_open.then_some(mode);
Task::none()
}
Message::DismissRenderModeMenu => {
self.render_mode_menu_open = false;
self.render_mode_preview = None;
Task::none()
}
Message::PreviewRenderMode(mode) => {
if self.render_mode_menu_open {
self.render_mode_preview = Some(mode);
}
Task::none()
}
Message::SetProjection(ortho) => {
use crate::scene::Projection;
+1
View File
@@ -1757,6 +1757,7 @@ impl OpenCADStudio {
world_width: 0.0,
depth_override: None,
fill_is_3d: false,
fill_is_2d_solid: false,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
+308 -22
View File
@@ -1,8 +1,8 @@
use super::*;
use super::super::document::{DynComponent, DynFieldEntry};
use super::super::Message;
use iced::widget::{button, container, mouse_area, row, tooltip};
use iced::{Background, Border, Element, Theme};
use iced::widget::{button, canvas, column, container, mouse_area, row, text, tooltip};
use iced::{Background, Border, Color, Element, Length, Point, Rectangle, Theme};
use std::time::Duration;
fn viewport_tooltip<'a>(
@@ -22,15 +22,200 @@ fn viewport_tooltip<'a>(
.into()
}
#[derive(Clone, Copy)]
struct RenderModePreview {
mode: acadrust::entities::ViewportRenderMode,
}
impl canvas::Program<Message> for RenderModePreview {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &Theme,
bounds: Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<canvas::Geometry> {
use acadrust::entities::ViewportRenderMode as M;
let mut frame = canvas::Frame::new(renderer, bounds.size());
let palette = theme.palette();
let ink = palette.background.base.text.scale_alpha(0.86);
let faint = ink.scale_alpha(0.34);
let accent = palette.primary.base.color;
let face = palette.primary.weak.color.scale_alpha(0.72);
let face_alt = palette.primary.strong.color.scale_alpha(0.64);
let edge = if self.mode == M::Wireframe3D { accent } else { ink };
let shaded = matches!(
self.mode,
M::FlatShaded
| M::GouraudShaded
| M::FlatShadedWithEdges
| M::GouraudShadedWithEdges
);
let flat = matches!(self.mode, M::FlatShaded | M::FlatShadedWithEdges);
let smooth = matches!(self.mode, M::GouraudShaded | M::GouraudShadedWithEdges);
let with_edges = matches!(
self.mode,
M::Wireframe2D
| M::Wireframe3D
| M::HiddenLine
| M::FlatShadedWithEdges
| M::GouraudShadedWithEdges
);
// A small cube makes hidden/back edges and the shaded-edge variants
// immediately distinguishable without depending on the open drawing.
let a = Point::new(25.0, 35.0);
let b = Point::new(87.0, 35.0);
let c = Point::new(87.0, 94.0);
let d = Point::new(25.0, 94.0);
let off = iced::Vector::new(22.0, -17.0);
let e = a + off;
let f = b + off;
let g = c + off;
let h = d + off;
let quad = |points: [Point; 4]| {
canvas::Path::new(|path| {
path.move_to(points[0]);
path.line_to(points[1]);
path.line_to(points[2]);
path.line_to(points[3]);
path.close();
})
};
if shaded || self.mode == M::HiddenLine {
frame.fill(&quad([a, b, f, e]), face_alt);
frame.fill(&quad([b, c, g, f]), face.scale_alpha(0.82));
frame.fill(&quad([a, d, c, b]), face);
}
let stroke_line = |frame: &mut canvas::Frame, from: Point, to: Point, color: Color| {
frame.stroke(
&canvas::Path::line(from, to),
canvas::Stroke::default().with_color(color).with_width(1.2),
);
};
if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) {
for (from, to) in [(e, f), (f, g), (g, h), (h, e)] {
stroke_line(&mut frame, from, to, faint);
}
}
if with_edges {
for (from, to) in [
(a, b), (b, c), (c, d), (d, a),
(a, e), (b, f), (c, g), (d, h),
(e, f), (f, g),
] {
stroke_line(&mut frame, from, to, edge);
}
}
// Curved sample: faceted tones for Flat, concentric highlight for
// Gouraud, and latitude/longitude lines for the wireframe modes.
let center = Point::new((bounds.width - 55.0).max(145.0), 59.0);
let radius = 32.0;
if flat {
for i in 0..12 {
let a0 = i as f32 * std::f32::consts::TAU / 12.0;
let a1 = (i + 1) as f32 * std::f32::consts::TAU / 12.0;
let wedge = canvas::Path::new(|path| {
path.move_to(center);
path.line_to(Point::new(center.x + radius * a0.cos(), center.y + radius * a0.sin()));
path.line_to(Point::new(center.x + radius * a1.cos(), center.y + radius * a1.sin()));
path.close();
});
frame.fill(&wedge, if i % 3 == 0 { face_alt } else { face });
}
} else if smooth {
frame.fill(&canvas::Path::circle(center, radius), face_alt);
frame.fill(
&canvas::Path::circle(Point::new(center.x - 7.0, center.y - 8.0), radius * 0.72),
face,
);
frame.fill(
&canvas::Path::circle(Point::new(center.x - 12.0, center.y - 13.0), radius * 0.34),
palette.primary.weak.text.scale_alpha(0.38),
);
}
if with_edges || self.mode == M::HiddenLine {
frame.stroke(
&canvas::Path::circle(center, radius),
canvas::Stroke::default().with_color(edge).with_width(1.2),
);
}
if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) {
for scale in [0.38_f32, 0.72] {
let y = radius * scale;
let half = (radius * radius - y * y).sqrt();
stroke_line(
&mut frame,
Point::new(center.x - half, center.y - y),
Point::new(center.x + half, center.y - y),
faint,
);
stroke_line(
&mut frame,
Point::new(center.x - half, center.y + y),
Point::new(center.x + half, center.y + y),
faint,
);
}
stroke_line(
&mut frame,
Point::new(center.x, center.y - radius),
Point::new(center.x, center.y + radius),
faint,
);
}
// Bottom samples show the intentional Wireframe 2D/3D distinction:
// the legacy planar solid loses only its interior in the 3D style,
// while a hatch-like pattern remains visible in both.
let solid = quad([
Point::new(18.0, 118.0),
Point::new(74.0, 112.0),
Point::new(69.0, 140.0),
Point::new(23.0, 143.0),
]);
if self.mode != M::Wireframe3D {
frame.fill(&solid, accent.scale_alpha(0.48));
}
frame.stroke(
&solid,
canvas::Stroke::default().with_color(edge).with_width(1.0),
);
let hatch_box = canvas::Path::rectangle(Point::new(105.0, 112.0), iced::Size::new(82.0, 31.0));
frame.stroke(
&hatch_box,
canvas::Stroke::default().with_color(ink).with_width(1.0),
);
for x in (94..188).step_by(9) {
let x = x as f32;
stroke_line(
&mut frame,
Point::new(x.max(105.0), 143.0),
Point::new((x + 24.0).min(187.0), 112.0),
faint,
);
}
vec![frame.into_geometry()]
}
}
pub(super) fn viewport_controls<'a>(
render_mode: acadrust::entities::ViewportRenderMode,
show_grid: bool,
snap_on: bool,
include_split: bool,
tile_count: usize,
render_mode_menu_open: bool,
render_mode_preview: Option<acadrust::entities::ViewportRenderMode>,
) -> Element<'a, Message> {
use acadrust::entities::ViewportRenderMode as M;
let render_modes: Vec<RenderModeChoice> = vec![
let render_modes = [
RenderModeChoice(M::Wireframe2D),
RenderModeChoice(M::Wireframe3D),
RenderModeChoice(M::HiddenLine),
@@ -101,34 +286,135 @@ pub(super) fn viewport_controls<'a>(
viewport_tooltip(button, title, command)
};
// Render-mode picker, restyled borderless so the outer chip frames it.
let picker = iced::widget::pick_list(
Some(RenderModeChoice(render_mode)),
render_modes,
|value| value.to_string(),
// The standard picker cannot show a live sample beside its rows. This
// flyout keeps the current style unchanged while hovering; a click commits.
let picker_head = button(
row![
text(RenderModeChoice(render_mode).to_string()).size(11),
crate::ui::icons::themed_arrow_toggle(render_mode_menu_open, 9.0),
]
.spacing(6)
.align_y(iced::Center),
)
.on_select(|c| Message::SetRenderMode(c.0))
.text_size(11)
.on_press(Message::ToggleRenderModeMenu(render_mode))
.padding([4, 6])
.style(move |theme: &Theme, _| {
let text = theme.palette().background.base.text;
iced::widget::pick_list::Style {
background: Background::Color(iced::Color::TRANSPARENT),
border: Border {
radius: 3.0.into(),
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let active = render_mode_menu_open
|| matches!(status, button::Status::Hovered | button::Status::Pressed);
button::Style {
background: active.then_some(Background::Color(palette.background.strong.color)),
text_color: if active {
palette.background.strong.text
} else {
palette.background.base.text
},
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
},
text_color: text,
placeholder_color: text.scale_alpha(0.68),
handle_color: text,
}
});
let picker = viewport_tooltip(
picker,
let picker_head = viewport_tooltip(
picker_head,
crate::t!("Visual Style").into_owned(),
"VISUALSTYLES",
);
let preview_mode = render_mode_preview.unwrap_or(render_mode);
let mut choices = column![].spacing(2).width(Length::Fixed(174.0));
for choice in render_modes {
let highlighted = choice.0 == preview_mode;
let selected = choice.0 == render_mode;
let option = container(text(choice.to_string()).size(11).width(Length::Fill))
.padding([6, 8])
.width(Length::Fill)
.style(move |theme: &Theme| {
let palette = theme.palette();
let active = highlighted;
container::Style {
background: active.then_some(Background::Color(
if selected { palette.primary.weak.color } else { palette.background.strong.color },
)),
text_color: if selected {
Some(palette.primary.weak.text)
} else if active {
Some(palette.background.strong.text)
} else {
Some(palette.background.base.text)
},
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
}
});
choices = choices.push(mouse_area(option)
.interaction(iced::mouse::Interaction::Pointer)
.on_enter(Message::PreviewRenderMode(choice.0))
.on_press(Message::SetRenderMode(choice.0)));
}
let preview = container(
column![
container(
text(RenderModeChoice(preview_mode).to_string())
.size(12)
.wrapping(iced::advanced::text::Wrapping::None),
)
.width(Length::Fixed(215.0))
.height(Length::Fixed(16.0))
.align_y(iced::alignment::Vertical::Center),
canvas(RenderModePreview { mode: preview_mode })
.width(Length::Fixed(215.0))
.height(Length::Fixed(154.0)),
]
.spacing(6),
)
.padding(8)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
text_color: Some(palette.background.base.text),
..Default::default()
}
});
let popup = container(
row![choices, preview]
.spacing(6)
.align_y(iced::alignment::Vertical::Top),
)
.padding(6)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(palette.background.weak.text),
..Default::default()
}
});
let picker: Element<'a, Message> = iced_aw::DropDown::new(
picker_head,
popup,
render_mode_menu_open,
)
.alignment(iced_aw::drop_down::Alignment::Bottom)
.offset(3.0)
.on_dismiss(Message::DismissRenderModeMenu)
.into();
// Thin vertical divider between control groups.
let sep = || {
container(iced::widget::Space::new().width(1.0).height(16.0)).style(|theme: &Theme| {
+21 -2
View File
@@ -199,6 +199,21 @@ impl OpenCADStudio {
theme_text.a,
];
let is_paper = tab.scene.current_layout != "Model";
let committed_render_mode = if is_paper {
tab.scene
.active_viewport_render_mode()
.unwrap_or(tab.render_mode)
} else {
tab.render_mode
};
// Gallery hover is a non-destructive live preview: only the shader's
// input changes. Dismissal restores the committed mode, while clicking
// a row follows the normal SetRenderMode path and persists it.
let viewport_render_mode = if self.render_mode_menu_open {
self.render_mode_preview.unwrap_or(committed_render_mode)
} else {
committed_render_mode
};
// Adaptive corner widgets: the ViewCube shows only while the active
// viewport is wide enough to hold it *beside* the render-mode bar, whose
// real width is measured each frame by its `DensitySwap` and read back
@@ -250,7 +265,7 @@ impl OpenCADStudio {
shader(ViewportPane::model(
&tab.scene,
viewcube_visible,
tab.render_mode,
viewport_render_mode,
viewcube_text_color,
))
.width(Fill)
@@ -268,7 +283,7 @@ impl OpenCADStudio {
// mouse_areas' hover state and drops their move events).
let scene = &tab.scene;
let show_viewcube = viewcube_visible;
let render_mode = tab.render_mode;
let render_mode = viewport_render_mode;
let size_probe: Element<'_, Message> = responsive(move |size| {
{
let mut sel = scene.selection.borrow_mut();
@@ -856,6 +871,8 @@ impl OpenCADStudio {
self.snapper.grid_snap(),
true,
tab.scene.model_tiles.borrow().len(),
self.render_mode_menu_open,
self.render_mode_preview,
);
// Adaptive: DensitySwap measures the bar's real width every frame
// (reported into `render_bar_w`, which the ViewCube reads to decide
@@ -946,6 +963,8 @@ impl OpenCADStudio {
self.snapper.grid_snap(),
false,
0,
self.render_mode_menu_open,
self.render_mode_preview,
);
let adaptive: Element<'_, Message> = DensitySwap::new(vec![
iced::widget::opaque(bar),