From abf0d8663a924edd551e37185486e6bff6f34e2a Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Fri, 30 Aug 2024 03:57:32 -0400 Subject: [PATCH] Fix blurry lines (#4943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Closes * [x] I have followed the instructions in the PR template I've been meaning to look into this for a while but finally bit the bullet this week. Contrary to what I initially thought, the problem of blurry lines is unrelated to feathering because it also happens with feathering disabled. The root cause is that lines tend to land on pixel boundaries, and because of that, frequently used strokes (e.g. 1pt), end up partially covering pixels. This is especially noticeable on 1ppp displays. There were a couple of things to fix, namely: individual lines like separators and indents but also shape strokes (e.g. Frame). Lines were easy, I just made sure we round them to the nearest pixel _center_, instead of the nearest pixel boundary. Strokes were a little more complicated. To illustrate why, here’s an example: if we're rendering a 5x5 rect (black fill, red stroke), we would expect to see something like this: ![Screenshot 2024-08-11 at 15 01 41](https://github.com/user-attachments/assets/5a5d4434-0814-451b-8179-2864dc73c6a6) The fill and the stroke to cover entire pixels. Instead, egui was painting the stroke partially inside and partially outside, centered around the shape’s path (blue line): ![Screenshot 2024-08-11 at 15 00 57](https://github.com/user-attachments/assets/4284dc91-5b6e-4422-994a-17d527a6f13b) Both methods are valid for different use-cases but the first one is what we’d typically want for UIs to feel crisp and pixel perfect. It's also how CSS borders work (related to #4019 and #3284). Luckily, we can use the normal computed for each `PathPoint` to adjust the location of the stroke to be outside, inside, or in the middle. These also are the 3 types of strokes available in tools like Photoshop. This PR introduces an enum `StrokeKind` which determines if a `PathStroke` should be tessellated outside, inside, or _on_ the path itself. Where "outside" is defined by the directions normals point to. Tessellator will now use `StrokeKind::Outside` for closed shapes like rect, ellipse, etc. And `StrokeKind::Middle` for the rest since there's no meaningful "outside" concept for open paths. This PR doesn't expose `StrokeKind` to user-land, but we can implement that later so that users can render shapes and decide where to place the stroke. ### Strokes test (blue lines represent the size of the rect being rendered) `Stroke::Middle` (current behavior, 1px and 3px are blurry) ![Screenshot 2024-08-09 at 23 55 48](https://github.com/user-attachments/assets/dabeaa9e-2010-4eb6-bd7e-b9cb3660542e) `Stroke::Outside` (proposed default behavior for closed paths) ![Screenshot 2024-08-09 at 23 51 55](https://github.com/user-attachments/assets/509c261f-0ae1-46a0-b9b8-08de31c3bd85) `Stroke::Inside` (for completeness but unused at the moment) ![Screenshot 2024-08-09 at 23 54 49](https://github.com/user-attachments/assets/c011b1c1-60ab-4577-baa9-14c36267438a) ### Demo App The best way to review this PR is to run the demo on a 1ppp display, especially to test hover effects. Everything should look crisper. Also run it in a higher dpi screen to test that nothing broke 🙏. Before: ![egui_old](https://github.com/user-attachments/assets/cd6e9032-d44f-4cb0-bb41-f9eb4c3ae810) After (notice the sharper lines): ![egui_new](https://github.com/user-attachments/assets/3365fc96-6eb2-4e7d-a2f5-b4712625a702) --- crates/egui/src/containers/panel.rs | 28 +++- crates/egui/src/containers/window.rs | 26 +-- crates/egui/src/context.rs | 24 ++- crates/egui/src/painter.rs | 14 +- crates/egui/src/style.rs | 8 +- crates/egui/src/ui.rs | 4 +- crates/egui/src/widgets/separator.rs | 4 +- crates/egui_demo_app/src/wrap_app.rs | 13 +- .../src/demo/demo_app_windows.rs | 1 + crates/egui_demo_lib/src/rendering_test.rs | 43 +++++ crates/epaint/src/stroke.rs | 49 ++++++ crates/epaint/src/tessellator.rs | 153 ++++++++++++------ 12 files changed, 278 insertions(+), 89 deletions(-) diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index eaffa0be15e..d6c0321f110 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -329,6 +329,9 @@ impl SidePanel { ui.ctx().set_cursor_icon(cursor_icon); } + // Keep this rect snapped so that panel content can be pixel-perfect + let rect = ui.painter().round_rect_to_pixels(rect); + PanelState { rect }.store(ui.ctx(), id); { @@ -343,10 +346,14 @@ impl SidePanel { Stroke::NONE }; // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done - // In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel - // (hence the shrink). - let resize_x = side.opposite().side_x(rect.shrink(1.0)); - let resize_x = ui.painter().round_to_pixel(resize_x); + let resize_x = side.opposite().side_x(rect); + + // This makes it pixel-perfect for odd-sized strokes (width=1.0, width=3.0, etc) + let resize_x = ui.painter().round_to_pixel_center(resize_x); + + // We want the line exactly on the last pixel but rust rounds away from zero so we bring it back a bit for + // left-side panels + let resize_x = resize_x - if side == Side::Left { 1.0 } else { 0.0 }; ui.painter().vline(resize_x, panel_rect.y_range(), stroke); } @@ -817,6 +824,9 @@ impl TopBottomPanel { ui.ctx().set_cursor_icon(cursor_icon); } + // Keep this rect snapped so that panel content can be pixel-perfect + let rect = ui.painter().round_rect_to_pixels(rect); + PanelState { rect }.store(ui.ctx(), id); { @@ -831,10 +841,12 @@ impl TopBottomPanel { Stroke::NONE }; // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done - // In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel - // (hence the shrink). - let resize_y = side.opposite().side_y(rect.shrink(1.0)); - let resize_y = ui.painter().round_to_pixel(resize_y); + let resize_y = side.opposite().side_y(rect); + let resize_y = ui.painter().round_to_pixel_center(resize_y); + + // We want the line exactly on the last pixel but rust rounds away from zero so we bring it back a bit for + // top-side panels + let resize_y = resize_y - if side == TopBottomSide::Top { 1.0 } else { 0.0 }; ui.painter().hline(panel_rect.x_range(), resize_y, stroke); } diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index eb7184c6e22..c183a4dc731 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -439,9 +439,6 @@ impl<'open> Window<'open> { let mut window_frame = frame.unwrap_or_else(|| Frame::window(&ctx.style())); // Keep the original inner margin for later use let window_margin = window_frame.inner_margin; - let border_padding = window_frame.stroke.width / 2.0; - // Add border padding to the inner margin to prevent it from covering the contents - window_frame.inner_margin += border_padding; let is_explicitly_closed = matches!(open, Some(false)); let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible()); @@ -575,9 +572,9 @@ impl<'open> Window<'open> { if let Some(title_bar) = title_bar { let mut title_rect = Rect::from_min_size( - outer_rect.min + vec2(border_padding, border_padding), + outer_rect.min, Vec2 { - x: outer_rect.size().x - border_padding * 2.0, + x: outer_rect.size().x, y: title_bar_height, }, ); @@ -587,9 +584,6 @@ impl<'open> Window<'open> { if on_top && area_content_ui.visuals().window_highlight_topmost { let mut round = window_frame.rounding; - // Eliminate the rounding gap between the title bar and the window frame - round -= border_padding; - if !is_collapsed { round.se = 0.0; round.sw = 0.0; @@ -603,7 +597,7 @@ impl<'open> Window<'open> { // Fix title bar separator line position if let Some(response) = &mut content_response { - response.rect.min.y = outer_rect.min.y + title_bar_height + border_padding; + response.rect.min.y = outer_rect.min.y + title_bar_height; } title_bar.ui( @@ -667,14 +661,10 @@ fn paint_resize_corner( } }; - // Adjust the corner offset to accommodate the stroke width and window rounding - let offset = if radius <= 2.0 && stroke.width < 2.0 { - 2.0 - } else { - // The corner offset is calculated to make the corner appear to be in the correct position - (2.0_f32.sqrt() * (1.0 + radius + stroke.width / 2.0) - radius) - * 45.0_f32.to_radians().cos() - }; + // Adjust the corner offset to accommodate for window rounding + let offset = + ((2.0_f32.sqrt() * (1.0 + radius) - radius) * 45.0_f32.to_radians().cos()).max(2.0); + let corner_size = Vec2::splat(ui.visuals().resize_corner_size); let corner_rect = corner.align_size_within_rect(corner_size, outer_rect); let corner_rect = corner_rect.translate(-offset * corner.to_sign()); // move away from corner @@ -1136,7 +1126,6 @@ impl TitleBar { let text_pos = emath::align::center_size_in_rect(self.title_galley.size(), full_top_rect).left_top(); let text_pos = text_pos - self.title_galley.rect.min.to_vec2(); - let text_pos = text_pos - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better) ui.painter().galley( text_pos, self.title_galley.clone(), @@ -1150,6 +1139,7 @@ impl TitleBar { let stroke = ui.visuals().widgets.noninteractive.bg_stroke; // Workaround: To prevent border infringement, // the 0.1 value should ideally be calculated using TessellationOptions::feathering_size_in_pixels + // or we could support selectively disabling feathering on line caps let x_range = outer_rect.x_range().shrink(0.1); ui.painter().hline(x_range, y, stroke); } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index ff13c14d764..7225af6d1ca 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1717,26 +1717,42 @@ impl Context { }); } - /// Useful for pixel-perfect rendering + /// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels). + #[inline] + pub(crate) fn round_to_pixel_center(&self, point: f32) -> f32 { + let pixels_per_point = self.pixels_per_point(); + ((point * pixels_per_point - 0.5).round() + 0.5) / pixels_per_point + } + + /// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels). + #[inline] + pub(crate) fn round_pos_to_pixel_center(&self, point: Pos2) -> Pos2 { + pos2( + self.round_to_pixel_center(point.x), + self.round_to_pixel_center(point.y), + ) + } + + /// Useful for pixel-perfect rendering of filled shapes #[inline] pub(crate) fn round_to_pixel(&self, point: f32) -> f32 { let pixels_per_point = self.pixels_per_point(); (point * pixels_per_point).round() / pixels_per_point } - /// Useful for pixel-perfect rendering + /// Useful for pixel-perfect rendering of filled shapes #[inline] pub(crate) fn round_pos_to_pixels(&self, pos: Pos2) -> Pos2 { pos2(self.round_to_pixel(pos.x), self.round_to_pixel(pos.y)) } - /// Useful for pixel-perfect rendering + /// Useful for pixel-perfect rendering of filled shapes #[inline] pub(crate) fn round_vec_to_pixels(&self, vec: Vec2) -> Vec2 { vec2(self.round_to_pixel(vec.x), self.round_to_pixel(vec.y)) } - /// Useful for pixel-perfect rendering + /// Useful for pixel-perfect rendering of filled shapes #[inline] pub(crate) fn round_rect_to_pixels(&self, rect: Rect) -> Rect { Rect { diff --git a/crates/egui/src/painter.rs b/crates/egui/src/painter.rs index b173310f756..a79deac5c9a 100644 --- a/crates/egui/src/painter.rs +++ b/crates/egui/src/painter.rs @@ -158,7 +158,19 @@ impl Painter { self.clip_rect = clip_rect; } - /// Useful for pixel-perfect rendering. + /// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels). + #[inline] + pub fn round_to_pixel_center(&self, point: f32) -> f32 { + self.ctx().round_to_pixel_center(point) + } + + /// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels). + #[inline] + pub fn round_pos_to_pixel_center(&self, pos: Pos2) -> Pos2 { + self.ctx().round_pos_to_pixel_center(pos) + } + + /// Useful for pixel-perfect rendering of filled shapes. #[inline] pub fn round_to_pixel(&self, point: f32) -> f32 { self.ctx().round_to_pixel(point) diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 46f391c0216..0097f53521f 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2477,8 +2477,12 @@ impl Widget for &mut Stroke { // stroke preview: let (_id, stroke_rect) = ui.allocate_space(ui.spacing().interact_size); - let left = stroke_rect.left_center(); - let right = stroke_rect.right_center(); + let left = ui + .painter() + .round_pos_to_pixel_center(stroke_rect.left_center()); + let right = ui + .painter() + .round_pos_to_pixel_center(stroke_rect.right_center()); ui.painter().line_segment([left, right], (*width, *color)); }) .response diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 8f9454ce45c..e502f11e148 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -2215,9 +2215,9 @@ impl Ui { let stroke = self.visuals().widgets.noninteractive.bg_stroke; let left_top = child_rect.min - 0.5 * indent * Vec2::X; - let left_top = self.painter().round_pos_to_pixels(left_top); + let left_top = self.painter().round_pos_to_pixel_center(left_top); let left_bottom = pos2(left_top.x, child_ui.min_rect().bottom() - 2.0); - let left_bottom = self.painter().round_pos_to_pixels(left_bottom); + let left_bottom = self.painter().round_pos_to_pixel_center(left_bottom); if left_vline { // draw a faint line on the left to mark the indented section diff --git a/crates/egui/src/widgets/separator.rs b/crates/egui/src/widgets/separator.rs index 7792bd23950..e421de9cf89 100644 --- a/crates/egui/src/widgets/separator.rs +++ b/crates/egui/src/widgets/separator.rs @@ -116,12 +116,12 @@ impl Widget for Separator { if is_horizontal_line { painter.hline( (rect.left() - grow)..=(rect.right() + grow), - painter.round_to_pixel(rect.center().y), + painter.round_to_pixel_center(rect.center().y), stroke, ); } else { painter.vline( - painter.round_to_pixel(rect.center().x), + painter.round_to_pixel_center(rect.center().x), (rect.top() - grow)..=(rect.bottom() + grow), stroke, ); diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 13e26f83b45..3805ce9a4cc 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -277,12 +277,14 @@ impl eframe::App for WrapApp { } let mut cmd = Command::Nothing; - egui::TopBottomPanel::top("wrap_app_top_bar").show(ctx, |ui| { - ui.horizontal_wrapped(|ui| { - ui.visuals_mut().button_frame = false; - self.bar_contents(ui, frame, &mut cmd); + egui::TopBottomPanel::top("wrap_app_top_bar") + .frame(egui::Frame::none().inner_margin(4.0)) + .show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + ui.visuals_mut().button_frame = false; + self.bar_contents(ui, frame, &mut cmd); + }); }); - }); self.state.backend_panel.update(ctx, frame); @@ -324,6 +326,7 @@ impl WrapApp { egui::SidePanel::left("backend_panel") .resizable(false) .show_animated(ctx, is_open, |ui| { + ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("💻 Backend"); }); diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 160ab2e6a60..576a69e66df 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -260,6 +260,7 @@ impl DemoWindows { .resizable(false) .default_width(150.0) .show(ctx, |ui| { + ui.add_space(4.0); ui.vertical_centered(|ui| { ui.heading("✒ egui demos"); }); diff --git a/crates/egui_demo_lib/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index f3c788a69e3..70fd9983768 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -415,6 +415,49 @@ pub fn pixel_test(ui: &mut Ui) { ui.add_space(4.0); pixel_test_squares(ui); + + ui.add_space(4.0); + + pixel_test_strokes(ui); +} + +fn pixel_test_strokes(ui: &mut Ui) { + ui.label("The strokes should align to the physical pixel grid."); + let color = if ui.style().visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + let pixels_per_point = ui.ctx().pixels_per_point(); + + for thickness_pixels in 1..=3 { + let thickness_pixels = thickness_pixels as f32; + let thickness_points = thickness_pixels / pixels_per_point; + let num_squares = (pixels_per_point * 10.0).round().max(10.0) as u32; + let size_pixels = vec2( + ui.available_width(), + num_squares as f32 + thickness_pixels * 2.0, + ); + let size_points = size_pixels / pixels_per_point + Vec2::splat(2.0); + let (response, painter) = ui.allocate_painter(size_points, Sense::hover()); + + let mut cursor_pixel = Pos2::new( + response.rect.min.x * pixels_per_point + thickness_pixels, + response.rect.min.y * pixels_per_point + thickness_pixels, + ) + .ceil(); + + let stroke = Stroke::new(thickness_points, color); + for size in 1..=num_squares { + let rect_points = Rect::from_min_size( + Pos2::new(cursor_pixel.x, cursor_pixel.y), + Vec2::splat(size as f32), + ); + painter.rect_stroke(rect_points / pixels_per_point, 0.0, stroke); + cursor_pixel.x += (1 + size) as f32 + thickness_pixels * 2.0; + } + } } fn pixel_test_squares(ui: &mut Ui) { diff --git a/crates/epaint/src/stroke.rs b/crates/epaint/src/stroke.rs index f1155bd07d6..399a602c259 100644 --- a/crates/epaint/src/stroke.rs +++ b/crates/epaint/src/stroke.rs @@ -55,6 +55,26 @@ impl std::hash::Hash for Stroke { } } +/// Describes how the stroke of a shape should be painted. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum StrokeKind { + /// The stroke should be painted entirely outside of the shape + Outside, + + /// The stroke should be painted entirely inside of the shape + Inside, + + /// The stroke should be painted right on the edge of the shape, half inside and half outside. + Middle, +} + +impl Default for StrokeKind { + fn default() -> Self { + Self::Middle + } +} + /// Describes the width and color of paths. The color can either be solid or provided by a callback. For more information, see [`ColorMode`] /// /// The default stroke is the same as [`Stroke::NONE`]. @@ -63,6 +83,7 @@ impl std::hash::Hash for Stroke { pub struct PathStroke { pub width: f32, pub color: ColorMode, + pub kind: StrokeKind, } impl PathStroke { @@ -70,6 +91,7 @@ impl PathStroke { pub const NONE: Self = Self { width: 0.0, color: ColorMode::TRANSPARENT, + kind: StrokeKind::Middle, }; #[inline] @@ -77,6 +99,7 @@ impl PathStroke { Self { width: width.into(), color: ColorMode::Solid(color.into()), + kind: StrokeKind::default(), } } @@ -91,6 +114,31 @@ impl PathStroke { Self { width: width.into(), color: ColorMode::UV(Arc::new(callback)), + kind: StrokeKind::default(), + } + } + + /// Set the stroke to be painted right on the edge of the shape, half inside and half outside. + pub fn middle(self) -> Self { + Self { + kind: StrokeKind::Middle, + ..self + } + } + + /// Set the stroke to be painted entirely outside of the shape + pub fn outside(self) -> Self { + Self { + kind: StrokeKind::Outside, + ..self + } + } + + /// Set the stroke to be painted entirely inside of the shape + pub fn inside(self) -> Self { + Self { + kind: StrokeKind::Inside, + ..self } } @@ -116,6 +164,7 @@ impl From for PathStroke { Self { width: value.width, color: ColorMode::Solid(value.color), + kind: StrokeKind::default(), } } } diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 6ee1a11a53d..1ef7c471880 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -303,7 +303,7 @@ mod precomputed_vertices { // ---------------------------------------------------------------------------- -#[derive(Clone, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] struct PathPoint { pos: Pos2, @@ -478,23 +478,23 @@ impl Path { } /// Open-ended. - pub fn stroke_open(&self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { - stroke_path(feathering, &self.0, PathType::Open, stroke, out); + pub fn stroke_open(&mut self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { + stroke_path(feathering, &mut self.0, PathType::Open, stroke, out); } /// A closed path (returning to the first point). - pub fn stroke_closed(&self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { - stroke_path(feathering, &self.0, PathType::Closed, stroke, out); + pub fn stroke_closed(&mut self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { + stroke_path(feathering, &mut self.0, PathType::Closed, stroke, out); } pub fn stroke( - &self, + &mut self, feathering: f32, path_type: PathType, stroke: &PathStroke, out: &mut Mesh, ) { - stroke_path(feathering, &self.0, path_type, stroke, out); + stroke_path(feathering, &mut self.0, path_type, stroke, out); } /// The path is taken to be closed (i.e. returning to the start again). @@ -502,8 +502,8 @@ impl Path { /// Calling this may reverse the vertices in the path if they are wrong winding order. /// /// The preferred winding order is clockwise. - pub fn fill(&mut self, feathering: f32, color: Color32, out: &mut Mesh) { - fill_closed_path(feathering, &mut self.0, color, out); + pub fn fill(&mut self, feathering: f32, color: Color32, stroke: &PathStroke, out: &mut Mesh) { + fill_closed_path(feathering, &mut self.0, color, stroke, out); } /// Like [`Self::fill`] but with texturing. @@ -536,8 +536,6 @@ pub mod path { let r = clamp_rounding(rounding, rect); if r == Rounding::ZERO { - let min = rect.min; - let max = rect.max; path.reserve(4); path.push(pos2(min.x, min.y)); // left top path.push(pos2(max.x, min.y)); // right top @@ -738,11 +736,31 @@ fn cw_signed_area(path: &[PathPoint]) -> f64 { /// Calling this may reverse the vertices in the path if they are wrong winding order. /// /// The preferred winding order is clockwise. -fn fill_closed_path(feathering: f32, path: &mut [PathPoint], color: Color32, out: &mut Mesh) { +/// +/// A stroke is required so that the fill's feathering can fade to the right color. You can pass `&PathStroke::NONE` if +/// this path won't be stroked. +fn fill_closed_path( + feathering: f32, + path: &mut [PathPoint], + color: Color32, + stroke: &PathStroke, + out: &mut Mesh, +) { if color == Color32::TRANSPARENT { return; } + // TODO(juancampa): This bounding box is computed twice per shape: once here and another when tessellating the + // stroke, consider hoisting that logic to the tessellator/scratchpad. + let bbox = Rect::from_points(&path.iter().map(|p| p.pos).collect::>()) + .expand((stroke.width / 2.0) + feathering); + + let stroke_color = &stroke.color; + let get_stroke_color: Box Color32> = match stroke_color { + ColorMode::Solid(col) => Box::new(|_pos: Pos2| *col), + ColorMode::UV(fun) => Box::new(|pos: Pos2| fun(bbox, pos)), + }; + let n = path.len() as u32; if feathering > 0.0 { if cw_signed_area(path) < 0.0 { @@ -755,7 +773,6 @@ fn fill_closed_path(feathering: f32, path: &mut [PathPoint], color: Color32, out out.reserve_triangles(3 * n as usize); out.reserve_vertices(2 * n as usize); - let color_outer = Color32::TRANSPARENT; let idx_inner = out.vertices.len() as u32; let idx_outer = idx_inner + 1; @@ -769,8 +786,13 @@ fn fill_closed_path(feathering: f32, path: &mut [PathPoint], color: Color32, out for i1 in 0..n { let p1 = &path[i1 as usize]; let dm = 0.5 * feathering * p1.normal; - out.colored_vertex(p1.pos - dm, color); - out.colored_vertex(p1.pos + dm, color_outer); + + let pos_inner = p1.pos - dm; + let pos_outer = p1.pos + dm; + let color_outer = get_stroke_color(pos_outer); + + out.colored_vertex(pos_inner, color); + out.colored_vertex(pos_outer, color_outer); out.add_triangle(idx_inner + i1 * 2, idx_inner + i0 * 2, idx_outer + 2 * i0); out.add_triangle(idx_outer + i0 * 2, idx_outer + i1 * 2, idx_inner + 2 * i1); i0 = i1; @@ -872,10 +894,24 @@ fn fill_closed_path_with_uv( } } +/// Translate a point along their normals according to the stroke kind. +#[inline(always)] +fn translate_stroke_point(p: &mut PathPoint, stroke: &PathStroke) { + match stroke.kind { + stroke::StrokeKind::Middle => { /* Nothingn to do */ } + stroke::StrokeKind::Outside => { + p.pos += p.normal * stroke.width * 0.5; + } + stroke::StrokeKind::Inside => { + p.pos -= p.normal * stroke.width * 0.5; + } + } +} + /// Tessellate the given path as a stroke with thickness. fn stroke_path( feathering: f32, - path: &[PathPoint], + path: &mut [PathPoint], path_type: PathType, stroke: &PathStroke, out: &mut Mesh, @@ -888,6 +924,12 @@ fn stroke_path( let idx = out.vertices.len() as u32; + // Translate the points along their normals if the stroke is outside or inside + if stroke.kind != stroke::StrokeKind::Middle { + path.iter_mut() + .for_each(|p| translate_stroke_point(p, stroke)); + } + // expand the bounding box to include the thickness of the path let bbox = Rect::from_points(&path.iter().map(|p| p.pos).collect::>()) .expand((stroke.width / 2.0) + feathering); @@ -924,7 +966,7 @@ fn stroke_path( let mut i0 = n - 1; for i1 in 0..n { let connect_with_previous = path_type == PathType::Closed || i1 > 0; - let p1 = &path[i1 as usize]; + let p1 = path[i1 as usize]; let p = p1.pos; let n = p1.normal; out.colored_vertex(p + n * feathering, color_outer); @@ -966,7 +1008,7 @@ fn stroke_path( let mut i0 = n - 1; for i1 in 0..n { - let p1 = &path[i1 as usize]; + let p1 = path[i1 as usize]; let p = p1.pos; let n = p1.normal; out.colored_vertex(p + n * outer_rad, color_outer); @@ -1011,7 +1053,7 @@ fn stroke_path( out.reserve_vertices(4 * n as usize); { - let end = &path[0]; + let end = path[0]; let p = end.pos; let n = end.normal; let back_extrude = n.rot90() * feathering; @@ -1032,7 +1074,7 @@ fn stroke_path( let mut i0 = 0; for i1 in 1..n - 1 { - let point = &path[i1 as usize]; + let point = path[i1 as usize]; let p = point.pos; let n = point.normal; out.colored_vertex(p + n * outer_rad, color_outer); @@ -1060,7 +1102,7 @@ fn stroke_path( { let i1 = n - 1; - let end = &path[i1 as usize]; + let end = path[i1 as usize]; let p = end.pos; let n = end.normal; let back_extrude = -n.rot90() * feathering; @@ -1227,11 +1269,20 @@ impl Tessellator { #[inline(always)] pub fn round_to_pixel(&self, point: f32) -> f32 { - if self.options.round_text_to_pixels { - (point * self.pixels_per_point).round() / self.pixels_per_point - } else { - point - } + (point * self.pixels_per_point).round() / self.pixels_per_point + } + + #[inline(always)] + pub fn round_to_pixel_center(&self, point: f32) -> f32 { + ((point * self.pixels_per_point - 0.5).round() + 0.5) / self.pixels_per_point + } + + #[inline(always)] + pub fn round_pos_to_pixel_center(&self, pos: Pos2) -> Pos2 { + pos2( + self.round_to_pixel_center(pos.x), + self.round_to_pixel_center(pos.y), + ) } /// Tessellate a clipped shape into a list of primitives. @@ -1404,11 +1455,13 @@ impl Tessellator { } } + let path_stroke = PathStroke::from(stroke).outside(); self.scratchpad_path.clear(); self.scratchpad_path.add_circle(center, radius); - self.scratchpad_path.fill(self.feathering, fill, out); self.scratchpad_path - .stroke_closed(self.feathering, &stroke.into(), out); + .fill(self.feathering, fill, &path_stroke, out); + self.scratchpad_path + .stroke_closed(self.feathering, &path_stroke, out); } /// Tessellate a single [`EllipseShape`] into a [`Mesh`]. @@ -1471,11 +1524,13 @@ impl Tessellator { points.push(center + Vec2::new(0.0, -radius.y)); points.extend(quarter.iter().rev().map(|p| center + Vec2::new(p.x, -p.y))); + let path_stroke = PathStroke::from(stroke).outside(); self.scratchpad_path.clear(); self.scratchpad_path.add_line_loop(&points); - self.scratchpad_path.fill(self.feathering, fill, out); self.scratchpad_path - .stroke_closed(self.feathering, &stroke.into(), out); + .fill(self.feathering, fill, &path_stroke, out); + self.scratchpad_path + .stroke_closed(self.feathering, &path_stroke, out); } /// Tessellate a single [`Mesh`] into a [`Mesh`]. @@ -1562,7 +1617,8 @@ impl Tessellator { closed, "You asked to fill a path that is not closed. That makes no sense." ); - self.scratchpad_path.fill(self.feathering, *fill, out); + self.scratchpad_path + .fill(self.feathering, *fill, stroke, out); } let typ = if *closed { PathType::Closed @@ -1650,7 +1706,7 @@ impl Tessellator { path.clear(); path::rounded_rectangle(&mut self.scratchpad_points, rect, rounding); path.add_line_loop(&self.scratchpad_points); - + let path_stroke = PathStroke::from(stroke).outside(); if uv.is_positive() { // Textured let uv_from_pos = |p: Pos2| { @@ -1662,10 +1718,9 @@ impl Tessellator { path.fill_with_uv(self.feathering, fill, fill_texture_id, uv_from_pos, out); } else { // Untextured - path.fill(self.feathering, fill, out); + path.fill(self.feathering, fill, &path_stroke, out); } - - path.stroke_closed(self.feathering, &stroke.into(), out); + path.stroke_closed(self.feathering, &path_stroke, out); } self.feathering = old_feathering; // restore @@ -1701,12 +1756,16 @@ impl Tessellator { out.vertices.reserve(galley.num_vertices); out.indices.reserve(galley.num_indices); - // The contents of the galley is already snapped to pixel coordinates, + // The contents of the galley are already snapped to pixel coordinates, // but we need to make sure the galley ends up on the start of a physical pixel: - let galley_pos = pos2( - self.round_to_pixel(galley_pos.x), - self.round_to_pixel(galley_pos.y), - ); + let galley_pos = if self.options.round_text_to_pixels { + pos2( + self.round_to_pixel(galley_pos.x), + self.round_to_pixel(galley_pos.y), + ) + } else { + *galley_pos + }; let uv_normalizer = vec2( 1.0 / self.font_tex_size[0] as f32, @@ -1782,13 +1841,12 @@ impl Tessellator { if *underline != Stroke::NONE { self.scratchpad_path.clear(); + self.scratchpad_path.add_line_segment([ + self.round_pos_to_pixel_center(row_rect.left_bottom()), + self.round_pos_to_pixel_center(row_rect.right_bottom()), + ]); self.scratchpad_path - .add_line_segment([row_rect.left_bottom(), row_rect.right_bottom()]); - self.scratchpad_path.stroke_open( - self.feathering, - &PathStroke::from(*underline), - out, - ); + .stroke_open(0.0, &PathStroke::from(*underline), out); } } } @@ -1872,7 +1930,8 @@ impl Tessellator { closed, "You asked to fill a path that is not closed. That makes no sense." ); - self.scratchpad_path.fill(self.feathering, fill, out); + self.scratchpad_path + .fill(self.feathering, fill, stroke, out); } let typ = if closed { PathType::Closed