Brush cursor: shaped tips trace their outline (split from #2516) (#3467)

Add the brush-outline half of #2516 on its own, so review and merge stay
small; the last-brush persistence half follows separately.

- paint: new `outline` module traces the tip shape as closed rings in
  radius units -- a computed ellipse for round tips (rotated/flattened),
  marching squares over the bitmap for sampled/stored tips, at the full
  tip or the 50 % alpha contour. Traced rings stay finite and bounded for
  hostile sizes; tested for the half-coverage contour, blank tips and
  hostile input.
- ui-egui: new `brush_cursor` picks the traced outline when the tip has a
  shape and falls back to the plain circle otherwise; `tool_cursor` gained
  the `Outline` shape (bitmap rasterisation by segment splatting, painter
  fallback on non-Windows).
- The Normal Brush Tip circle now goes through `half_coverage_radius`
  (#2744) so outline and circle agree on the real 50 % contour.

Review fixes from #2516:
- Merged main; docs/scorecard.md is main's version and was verified with
  `cargo xtask scorecard --check` (regeneration changes nothing).
- Dropped the scratch benchmark `crates/paint/examples/tmp_outline_bench.rs`
  and the two `#[ignore = "temp bench"]` tests in tool_cursor/tests.rs.

Co-authored-by: JJ <jj@dot.com>
This commit is contained in:
siraxe 2026-10-12 03:23:29 +00:00 committed by GitHub
parent 17855ab3d1
commit 80b8deab0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 579 additions and 47 deletions

View File

@ -22,6 +22,7 @@ pub mod bg_erase;
pub mod brush;
pub mod dynamics;
pub mod mixer;
pub mod outline;
pub mod presets;
pub mod procedural;
pub mod render;

324
crates/paint/src/outline.rs Normal file
View File

@ -0,0 +1,324 @@
//! Brush tip outlines for the painting cursor: closed rings in units of the brush radius
//! (1 = size / 2), screen-oriented (x right, y down), ready for the UI to scale by the radius.
use std::collections::HashMap;
use crate::brush::{BrushSettings, TipShape};
use crate::tile::GrayTile;
/// Segments in the outline of a computed round tip.
const ELLIPSE_SEGMENTS: usize = 64;
/// Budget for all traced ring vertices; the smallest rings are dropped first.
const MAX_POINTS: usize = 512;
/// A traced ring smaller than this many bitmap pixels squared is noise.
const MIN_AREA: f32 = 0.9;
/// "Full size" outlines everything above 1/255; the normal cursor outlines the 50 % contour.
const FULL_THRESHOLD: f32 = 1.0 / 255.0;
const HALF_THRESHOLD: f32 = 0.5;
/// The tip outline as closed rings, or `None` when the tip has no usable shape. `full` outlines
/// the whole tip; otherwise the 50 % contour (the Photoshop normal brush tip cursor).
pub fn tip_outline(b: &BrushSettings, full: bool) -> Option<Vec<Vec<[f32; 2]>>> {
if !b.size.is_finite() || b.size <= 0.0 || !b.angle.is_finite() || !b.roundness.is_finite() {
return None;
}
let ro = b.roundness.clamp(0.0, 1.0).max(0.5 / (b.size / 2.0)).min(1.0);
let (sn, cs) = b.angle.to_radians().sin_cos();
let (fx, fy) = (if b.flip_x { -1.0 } else { 1.0 }, if b.flip_y { -1.0 } else { 1.0 });
match &b.tip {
TipShape::Round => {
// The normal cursor is the 50 % contour, matching the plain-circle path (#2744):
// the soft tip falls off as (1 - t²)⁴, not the old smoothstep midpoint.
let f = if full { 1.0 } else { crate::half_coverage_radius(1.0, b.hardness) };
let ring = (0..ELLIPSE_SEGMENTS)
.map(|i| {
let t = std::f32::consts::TAU * i as f32 / ELLIPSE_SEGMENTS as f32;
// Tip-local (y up), rotated CCW by the angle, then flipped to screen y down.
let (lx, ly) = (t.cos() * f, t.sin() * f * ro);
[lx * cs - ly * sn, -(lx * sn + ly * cs)]
})
.collect();
Some(vec![ring])
}
TipShape::Sampled(_) | TipShape::Stored(_) => {
let t = b.tip.bitmap()?;
if !t.is_valid() {
return None;
}
trace(t, if full { FULL_THRESHOLD } else { HALF_THRESHOLD }, (sn, cs, fx, fy, ro))
}
}
}
/// Trace the contour(s) above `thr` (resampled ~2x, capped at 160 px a side) and map them with
/// the same tip transform as `render::BrushContext::rasterize`.
fn trace(t: &GrayTile, thr: f32, xf: (f32, f32, f32, f32, f32)) -> Option<Vec<Vec<[f32; 2]>>> {
let (sn, cs, fx, fy, ro) = xf;
let (tw, th) = (t.width as f32, t.height as f32);
let big = tw.max(th);
let f = if big <= 160.0 { 2.0 } else { 160.0 / big };
let (gw, gh) = (((tw * f).ceil() as usize).max(1), ((th * f).ceil() as usize).max(1));
let mut vals = vec![0.0f32; gw * gh];
for gy in 0..gh {
for gx in 0..gw {
let (px, py) = ((gx as f32 + 0.5) / f - 0.5, (gy as f32 + 0.5) / f - 0.5);
vals[gy * gw + gx] = sample(t, px, py);
}
}
let mut rings: Vec<Vec<[f32; 2]>> = march(&vals, gw, gh, thr).into_iter().filter(|r| area(r) >= MIN_AREA * f * f).collect();
if rings.is_empty() {
return None;
}
rings.sort_by(|a, b| area(b).total_cmp(&area(a)));
let mut tol = 0.5;
let mut thin: Vec<Vec<[f32; 2]>>;
loop {
thin = rings.iter().map(|r| simplify(r, tol)).collect();
if thin.iter().map(Vec::len).sum::<usize>() <= MAX_POINTS || tol >= 8.0 {
break;
}
tol *= 1.5;
}
let mut budget = MAX_POINTS;
thin.retain(|r| {
let keep = r.len() >= 3 && r.len() <= budget;
if keep {
budget -= r.len();
}
keep
});
if thin.is_empty() {
return None;
}
let s = 2.0 / big;
let map = |[gx, gy]: [f32; 2]| {
let (px, py) = ((gx + 0.5) / f - 0.5, (gy + 0.5) / f - 0.5);
let (u, v) = ((px - tw / 2.0) * s * fx, -(py - th / 2.0) * s * ro * fy);
let (ux, uy) = (u * cs - v * sn, u * sn + v * cs);
[ux, -uy]
};
let rings: Vec<Vec<[f32; 2]>> = thin.into_iter().map(|r| r.into_iter().map(map).collect()).collect();
rings.iter().flatten().all(|p| p[0].is_finite() && p[1].is_finite()).then_some(rings)
}
/// Bilinear sample with zero outside (tips fade to nothing at their edges).
fn sample(t: &GrayTile, x: f32, y: f32) -> f32 {
let (x0, y0) = (x.floor(), y.floor());
let (tx, ty) = (x - x0, y - y0);
let (x0, y0) = (x0 as i64, y0 as i64);
let get = |x: i64, y: i64| if x < 0 || y < 0 || x >= t.width as i64 || y >= t.height as i64 { 0.0 } else { t.get(x as u32, y as u32) };
let a = get(x0, y0) + (get(x0 + 1, y0) - get(x0, y0)) * tx;
let b = get(x0, y0 + 1) + (get(x0 + 1, y0 + 1) - get(x0, y0 + 1)) * tx;
a + (b - a) * ty
}
/// Shoelace area of a ring.
fn area(r: &[[f32; 2]]) -> f32 {
if r.len() < 3 {
return 0.0;
}
let mut s = 0.0;
for i in 0..r.len() {
let (a, b) = (r[i], r[(i + 1) % r.len()]);
s += a[0] * b[1] - b[0] * a[1];
}
(s * 0.5).abs()
}
/// Link segments end-to-end into closed rings (quantised endpoint keys absorb float noise).
fn stitch(segs: Vec<([f32; 2], [f32; 2])>) -> Vec<Vec<[f32; 2]>> {
let key = |p: [f32; 2]| ((p[0] * 4096.0).round() as i64, (p[1] * 4096.0).round() as i64);
let mut ends: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, (a, b)) in segs.iter().enumerate() {
ends.entry(key(*a)).or_default().push(i);
ends.entry(key(*b)).or_default().push(i);
}
let mut used = vec![false; segs.len()];
let mut rings = Vec::new();
for s in 0..segs.len() {
if used[s] {
continue;
}
used[s] = true;
let mut ring = vec![segs[s].0, segs[s].1];
while let Some(tail) = ring.last().copied() {
let Some(&next) = ends.get(&key(tail)).and_then(|c| c.iter().find(|&&i| !used[i])) else { break };
used[next] = true;
let (a, b) = segs[next];
let p = if key(a) == key(tail) { b } else { a };
if key(p) == key(ring[0]) {
break;
}
ring.push(p);
}
if ring.len() >= 3 {
rings.push(ring);
}
}
rings
}
/// Marching squares over `v` (w by h samples at lattice nodes), outside treated as 0 so every
/// contour closes; returns rings in node coordinates.
fn march(v: &[f32], w: usize, h: usize, thr: f32) -> Vec<Vec<[f32; 2]>> {
let val = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0.0 } else { v[y as usize * w + x as usize] } };
let lerp = |v0: f32, v1: f32, a: (f32, f32), b: (f32, f32)| -> [f32; 2] {
let t = if (v1 - v0).abs() < 1e-9 { 0.5 } else { ((thr - v0) / (v1 - v0)).clamp(0.0, 1.0) };
[a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t]
};
let mut segs: Vec<([f32; 2], [f32; 2])> = Vec::new();
for y in -1..h as i64 {
for x in -1..w as i64 {
let (va, vb, vc, vd) = (val(x, y), val(x + 1, y), val(x + 1, y + 1), val(x, y + 1));
let idx = u8::from(va >= thr) | (u8::from(vb >= thr) << 1) | (u8::from(vc >= thr) << 2) | (u8::from(vd >= thr) << 3);
if idx == 0 || idx == 15 {
continue;
}
let (fx, fy) = (x as f32, y as f32);
let top = lerp(va, vb, (fx, fy), (fx + 1.0, fy));
let right = lerp(vb, vc, (fx + 1.0, fy), (fx + 1.0, fy + 1.0));
let bottom = lerp(vd, vc, (fx, fy + 1.0), (fx + 1.0, fy + 1.0));
let left = lerp(va, vd, (fx, fy), (fx, fy + 1.0));
let mid = (va + vb + vc + vd) * 0.25 >= thr;
match idx {
1 | 14 => segs.push((top, left)),
2 | 13 => segs.push((top, right)),
3 | 12 => segs.push((right, left)),
4 | 11 => segs.push((right, bottom)),
6 | 9 => segs.push((top, bottom)),
7 | 8 => segs.push((left, bottom)),
5 | 10 => {
let opposite = (idx == 5) != mid;
if opposite {
segs.push((top, right));
segs.push((left, bottom));
} else {
segs.push((top, left));
segs.push((right, bottom));
}
}
_ => {}
}
}
}
stitch(segs)
}
/// Douglas-Peucker on a closed ring (split at the point farthest from the first).
fn simplify(ring: &[[f32; 2]], tol: f32) -> Vec<[f32; 2]> {
if ring.len() <= 4 {
return ring.to_vec();
}
let (mut far, mut best) = (0, 0.0);
for (i, p) in ring.iter().enumerate().skip(1) {
let d = (p[0] - ring[0][0]).powi(2) + (p[1] - ring[0][1]).powi(2);
if d > best {
best = d;
far = i;
}
}
let mut open: Vec<[f32; 2]> = ring[far..].to_vec();
open.push(ring[0]);
let mut out = Vec::new();
dp(&ring[..=far], tol, &mut out);
out.pop();
dp(&open, tol, &mut out);
out.pop();
out
}
/// Iterative Douglas-Peucker: keep the points of `pts` farther than `tol` from the chord.
fn dp(pts: &[[f32; 2]], tol: f32, out: &mut Vec<[f32; 2]>) {
let n = pts.len();
if n == 0 {
return;
}
if n < 3 {
out.extend_from_slice(pts);
return;
}
let mut keep = vec![false; n];
keep[0] = true;
keep[n - 1] = true;
let mut stack = vec![(0usize, n - 1)];
while let Some((i, j)) = stack.pop() {
if j <= i + 1 {
continue;
}
let (mut dmax, mut k) = (0.0f32, i);
for m in i + 1..j {
let d = seg_dist2(pts[m], pts[i], pts[j]);
if d > dmax {
dmax = d;
k = m;
}
}
if dmax > tol * tol {
keep[k] = true;
stack.push((i, k));
stack.push((k, j));
}
}
for (i, p) in pts.iter().enumerate() {
if keep[i] {
out.push(*p);
}
}
}
/// Squared distance from `p` to the segment `a`-`b`.
fn seg_dist2(p: [f32; 2], a: [f32; 2], b: [f32; 2]) -> f32 {
let (abx, aby) = (b[0] - a[0], b[1] - a[1]);
let t = (((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / (abx * abx + aby * aby).max(1e-12)).clamp(0.0, 1.0);
(p[0] - a[0] - abx * t).powi(2) + (p[1] - a[1] - aby * t).powi(2)
}
#[cfg(test)]
mod tests {
use super::*;
fn max_radius(rings: &[Vec<[f32; 2]>]) -> f32 {
rings.iter().flatten().fold(0.0f32, |m, p| m.max(p[0].hypot(p[1])))
}
/// The normal cursor of a rotated or flattened round tip is the same 50 % contour as the
/// plain-circle path (#2744), and the full cursor reaches the whole radius.
#[test]
fn round_outlines_sit_on_the_half_coverage_contour() {
for hardness in [0.0, 0.25, 0.5, 0.75, 1.0] {
let b = BrushSettings { size: 40.0, angle: 33.0, roundness: 0.4, hardness, ..Default::default() };
let normal = tip_outline(&b, false).expect("a rotated round tip traces");
let full = tip_outline(&b, true).expect("a rotated round tip traces");
assert!((max_radius(&normal) - crate::half_coverage_radius(1.0, hardness)).abs() < 1e-4, "hardness {hardness}");
assert!((max_radius(&full) - 1.0).abs() < 1e-4, "hardness {hardness}");
}
}
/// A sampled tip traces its 50 % alpha contour, inside the tip's square; a blank tip has none.
#[test]
fn sampled_outlines_stay_inside_the_tip_and_blank_tips_have_none() {
let disc = GrayTile::from_fn(32, 32, |x, y| {
let (dx, dy) = (x as f32 - 15.5, y as f32 - 15.5);
(1.0 - (dx * dx + dy * dy).sqrt() / 16.0).clamp(0.0, 1.0)
});
let b = BrushSettings { size: 20.0, tip: TipShape::Sampled(disc), ..Default::default() };
let rings = tip_outline(&b, false).expect("a sampled disc traces");
assert!(rings.iter().all(|r| r.len() >= 3));
assert!(rings.iter().flatten().all(|p| p[0].is_finite() && p[1].is_finite() && p[0].hypot(p[1]) <= 1.001));
let blank = GrayTile::from_fn(8, 8, |_, _| 0.0);
let b = BrushSettings { size: 20.0, tip: TipShape::Sampled(blank), ..Default::default() };
assert!(tip_outline(&b, false).is_none());
}
/// Hostile brush data yields no outline instead of NaNs or a panic.
#[test]
fn hostile_settings_get_no_outline() {
for size in [f32::NAN, f32::INFINITY, 0.0, -1.0] {
let b = BrushSettings { size, angle: 33.0, roundness: 0.5, ..Default::default() };
assert!(tip_outline(&b, false).is_none(), "size {size}");
}
for angle in [f32::NAN, f32::INFINITY] {
let b = BrushSettings { size: 40.0, angle, roundness: 0.5, ..Default::default() };
assert!(tip_outline(&b, false).is_none(), "angle {angle}");
}
}
}

View File

@ -0,0 +1,75 @@
//! The painting cursor for brushes with a tip shape: sampled tip outlines and rotated or
//! flattened round tips. Everything else falls back to the plain circle (`tool_cursor`).
use std::sync::Arc;
use egui::{CursorIcon, Painter, Pos2, Vec2};
use photocraft_paint::brush::{BrushSettings, TipShape};
/// Below this on-screen radius an outline is unreadable: show the crosshair instead.
const MIN_RADIUS: f32 = 3.0;
#[derive(Clone)]
struct Cached {
key: Key,
rings: Option<Arc<Vec<Vec<[f32; 2]>>>>,
}
/// The brush fields an outline depends on.
#[derive(Clone)]
struct Key {
tip: TipShape,
angle: f32,
roundness: f32,
hardness: f32,
flip_x: bool,
flip_y: bool,
full: bool,
}
impl Key {
fn of(b: &BrushSettings, full: bool) -> Self {
Self { tip: b.tip.clone(), angle: b.angle, roundness: b.roundness, hardness: b.hardness, flip_x: b.flip_x, flip_y: b.flip_y, full }
}
fn matches(&self, b: &BrushSettings, full: bool) -> bool {
self.tip == b.tip
&& self.angle == b.angle
&& self.roundness == b.roundness
&& self.hardness == b.hardness
&& self.flip_x == b.flip_x
&& self.flip_y == b.flip_y
&& self.full == full
}
}
/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom);
/// `normal` is Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip).
pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, normal: bool, centre: bool) -> CursorIcon {
if let Some(rings) = rings(painter.ctx(), brush, !normal) {
let pts: Vec<Vec<Vec2>> = rings.iter().map(|r| r.iter().map(|p| Vec2::new(p[0], p[1]) * radius).collect()).collect();
let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length()));
if extent < MIN_RADIUS {
return crate::tool_cursor::crosshair(painter, at, 4.0, 0.0);
}
return crate::tool_cursor::outline(painter, at, pts, centre);
}
let r = if normal { photocraft_paint::half_coverage_radius(radius, brush.hardness).max(1.0) } else { radius };
crate::tool_cursor::circle(painter, at, r, centre)
}
/// The tip outline in radius units, cached across frames; `None` when the plain circle says it all
/// (a plain round tip) or the tip has no traceable shape.
fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option<Arc<Vec<Vec<[f32; 2]>>>> {
if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 {
return None;
}
let id = egui::Id::new("photocraft-tip-outline");
if let Some(c) = ctx.data(|d| d.get_temp::<Cached>(id))
&& c.key.matches(brush, full)
{
return c.rings;
}
let traced = photocraft_paint::outline::tip_outline(brush, full).map(Arc::new);
ctx.data_mut(|d| d.insert_temp(id, Cached { key: Key::of(brush, full), rings: traced.clone() }));
traced
}

View File

@ -3419,9 +3419,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
egui::CursorIcon::None
}
// A shaped tip (sampled bitmap, or a rotated/flattened round tip)
// traces its outline; a plain round tip keeps the circle.
_ => {
let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r);
crate::tool_cursor::circle(&painter, p, r, centre)
crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre)
}
}
}

View File

@ -27,6 +27,7 @@ pub mod artboard_ui;
mod blend_keys;
pub(crate) mod blend_preview;
mod brand;
pub(crate) mod brush_cursor;
pub mod brush_panel;
pub mod brush_picker;
pub mod brush_preview;

View File

@ -7,7 +7,7 @@
use std::sync::Arc;
use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, vec2};
use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2};
mod hand;
@ -19,11 +19,13 @@ mod hand;
const MAX_SIDE: u16 = if cfg!(target_os = "windows") { 2048 } else { 255 };
/// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags.
#[derive(Clone, Copy, Debug, PartialEq)]
/// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot.
#[derive(Clone, Debug, PartialEq)]
enum Shape {
Circle { radius: f32, centre: bool },
Crosshair { length: f32, gap: f32 },
Hand { closed: bool },
Outline { rings: Arc<[Vec<Vec2>]>, centre: bool },
}
#[derive(Clone)]
@ -69,6 +71,10 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C
show(painter, at, Shape::Crosshair { length, gap })
}
pub(crate) fn outline(painter: &Painter, at: Pos2, rings: Vec<Vec<Vec2>>, centre: bool) -> CursorIcon {
show(painter, at, Shape::Outline { rings: rings.into(), centre })
}
/// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration
/// don't upload cursor images; every desktop root viewport does (winit custom cursors).
fn native(ctx: &egui::Context) -> bool {
@ -91,7 +97,7 @@ pub(crate) fn hand(ctx: &egui::Context, closed: bool) -> CursorIcon {
}
fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon {
if bitmap && let Some(image) = cached_image(ctx, Shape::Hand { closed }, native_scale(ctx)) {
if bitmap && let Some(image) = cached_image(ctx, &Shape::Hand { closed }, native_scale(ctx)) {
ctx.set_cursor_image(Some(image));
return CursorIcon::None;
}
@ -103,36 +109,36 @@ fn show(painter: &Painter, at: Pos2, shape: Shape) -> CursorIcon {
// Where cursor images can't be uploaded (immediate viewports, the web), paint the shape.
if native(ctx) {
let scale = native_scale(ctx);
if let Some(image) = cached_image(ctx, shape, scale) {
if let Some(image) = cached_image(ctx, &shape, scale) {
ctx.set_cursor_image(Some(image));
return CursorIcon::None;
}
// An enormous tip cannot fit an OS cursor. Preserve its full-size outline, with an OS
// crosshair at the hotspot so the user still gets immediate position feedback.
paint(painter, at, shape);
if let Some(image) = cached_image(ctx, Shape::Crosshair { length: 6.0, gap: 2.0 }, scale) {
paint(painter, at, &shape);
if let Some(image) = cached_image(ctx, &Shape::Crosshair { length: 6.0, gap: 2.0 }, scale) {
ctx.set_cursor_image(Some(image));
}
return CursorIcon::None;
}
paint(painter, at, shape);
paint(painter, at, &shape);
CursorIcon::None
}
fn cached_image(ctx: &egui::Context, shape: Shape, scale: f32) -> Option<CustomCursorImage> {
fn cached_image(ctx: &egui::Context, shape: &Shape, scale: f32) -> Option<CustomCursorImage> {
let id = egui::Id::new("photocraft-tool-cursor");
if let Some(cached) = ctx.data(|d| d.get_temp::<Cached>(id))
&& cached.shape == shape
&& cached.shape == *shape
&& cached.scale == scale
{
return Some(cached.image);
}
let image = rasterize(shape, scale)?;
ctx.data_mut(|d| d.insert_temp(id, Cached { shape, scale, image: image.clone() }));
ctx.data_mut(|d| d.insert_temp(id, Cached { shape: shape.clone(), scale, image: image.clone() }));
Some(image)
}
fn paint(painter: &Painter, at: Pos2, shape: Shape) {
fn paint(painter: &Painter, at: Pos2, shape: &Shape) {
if extent(shape).is_none() {
return;
}
@ -141,12 +147,22 @@ fn paint(painter: &Painter, at: Pos2, shape: Shape) {
let stroke = Stroke::new(width, color);
match shape {
Shape::Circle { radius, centre } => {
painter.circle_stroke(at, radius, stroke);
if centre {
painter.circle_stroke(at, *radius, stroke);
if *centre {
paint_crosshair(painter, at, 3.0, 0.0, stroke);
}
}
Shape::Crosshair { length, gap } => paint_crosshair(painter, at, *length, *gap, stroke),
Shape::Outline { rings, centre } => {
for ring in rings.iter() {
if ring.len() >= 2 {
painter.add(egui::Shape::closed_line(ring.iter().map(|v| at + *v).collect(), stroke));
}
}
if *centre {
paint_crosshair(painter, at, 3.0, 0.0, stroke);
}
}
Shape::Crosshair { length, gap } => paint_crosshair(painter, at, length, gap, stroke),
// Only ever a bitmap: without one the OS draws its own grab cursors.
Shape::Hand { .. } => {}
}
@ -166,42 +182,81 @@ fn cross_distance(x: f32, y: f32, length: f32, gap: f32) -> f32 {
horizontal.min(vertical)
}
fn extent(shape: Shape) -> Option<f32> {
fn extent(shape: &Shape) -> Option<f32> {
Some(match shape {
Shape::Circle { radius, centre } => {
if !radius.is_finite() || radius <= 0.0 {
if !radius.is_finite() || *radius <= 0.0 {
return None;
}
if centre { radius.max(3.0) } else { radius }
if *centre { radius.max(3.0) } else { *radius }
}
Shape::Crosshair { length, gap } => {
if !length.is_finite() || !gap.is_finite() || gap < 0.0 || length < gap {
if !length.is_finite() || !gap.is_finite() || *gap < 0.0 || length < gap {
return None;
}
length
*length
}
Shape::Outline { rings, centre } => {
let mut e = 0.0f32;
for ring in rings.iter() {
for p in ring {
if !p.x.is_finite() || !p.y.is_finite() {
return None;
}
e = e.max(p.x.hypot(p.y));
}
}
if e <= 0.0 {
return None;
}
if *centre { e.max(3.0) } else { e }
}
Shape::Hand { .. } => hand::HALF,
})
}
/// Distance from (`x`, `y`) to the segment `a`-`b`, all in the same units.
fn seg_distance(x: f32, y: f32, a: Vec2, b: Vec2) -> f32 {
let (abx, aby) = (b.x - a.x, b.y - a.y);
let t = (((x - a.x) * abx + (y - a.y) * aby) / (abx * abx + aby * aby).max(1e-12)).clamp(0.0, 1.0);
((x - a.x - abx * t).powi(2) + (y - a.y - aby * t).powi(2)).sqrt()
}
/// How much of the pixel at `(dx, dy)` physical pixels from the hotspot the light fill covers and
/// how much the dark outline around it does, each 0 to 1. A dark crease inside the hand takes
/// from the fill, so the fill never covers it.
fn coverage(shape: Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) {
fn coverage(shape: &Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) {
let (x, y) = (dx.abs(), dy.abs());
match shape {
Shape::Circle { radius, centre } => {
let ring = (x.hypot(y) - radius * scale).abs();
let distance = if centre { ring.min(cross_distance(x, y, 3.0 * scale, 0.0)) } else { ring };
let distance = if *centre { ring.min(cross_distance(x, y, 3.0 * scale, 0.0)) } else { ring };
stroke_coverage(distance, scale)
}
Shape::Crosshair { length, gap } => stroke_coverage(cross_distance(x, y, length * scale, gap * scale), scale),
Shape::Outline { rings, centre } => {
let (lx, ly) = (dx / scale, dy / scale);
let mut distance = f32::INFINITY;
for ring in rings.iter() {
let n = ring.len();
if n < 2 {
continue;
}
for (i, &a) in ring.iter().enumerate() {
distance = distance.min(seg_distance(lx, ly, a, ring[(i + 1) % n]));
}
}
if *centre {
distance = distance.min(cross_distance(dx, dy, 3.0 * scale, 0.0));
}
stroke_coverage(distance * scale, scale)
}
Shape::Hand { closed } => {
let (lx, ly) = (dx / scale, dy / scale);
let edge = hand::shape(closed, lx, ly) * scale;
let edge = hand::shape(*closed, lx, ly) * scale;
let inside = (0.5 - edge).clamp(0.0, 1.0);
let outline = (scale + 0.5 - edge).clamp(0.0, 1.0);
let crease = (0.45 * scale + 0.5 - hand::crease(closed, lx, ly) * scale).clamp(0.0, 1.0);
let crease = (0.45 * scale + 0.5 - hand::crease(*closed, lx, ly) * scale).clamp(0.0, 1.0);
(inside * (1.0 - crease), outline.max(inside * crease))
}
}
@ -212,7 +267,57 @@ fn stroke_coverage(distance: f32, scale: f32) -> (f32, f32) {
((0.5 * scale + 0.5 - distance).clamp(0.0, 1.0), (1.5 * scale + 0.5 - distance).clamp(0.0, 1.0))
}
fn rasterize(shape: Shape, scale: f32) -> Option<CustomCursorImage> {
/// Splat the distance to one ring segment into an outline's side × side distance buffer.
fn splat_segment(dist: &mut [f32], side: usize, hot: f32, a: Vec2, b: Vec2, reach: f32) {
let n = side as f32;
let (x0, x1) = (a.x.min(b.x) + hot - reach, a.x.max(b.x) + hot + reach);
let (y0, y1) = (a.y.min(b.y) + hot - reach, a.y.max(b.y) + hot + reach);
if x1 < 0.0 || y1 < 0.0 || x0 > n - 1.0 || y0 > n - 1.0 {
return;
}
let (c0, c1) = (x0.max(0.0).floor() as i32, x1.min(n - 1.0).ceil() as i32);
let (r0, r1) = (y0.max(0.0).floor() as i32, y1.min(n - 1.0).ceil() as i32);
for row in r0..=r1 {
let dy = row as f32 - hot;
for col in c0..=c1 {
let d = seg_distance(col as f32 - hot, dy, a, b);
let i = row as usize * side + col as usize;
if d < dist[i] {
dist[i] = d;
}
}
}
}
/// Fold the centre crosshair (the circle cursor's) into the outline distances.
fn splat_cross(dist: &mut [f32], side: usize, hot: f32, arm: f32) {
let reach = arm + 2.0;
let lo = (hot - reach).max(0.0).floor() as i32;
let hi = ((hot + reach).min(side as f32 - 1.0)).ceil() as i32;
for row in lo..=hi {
let dy = row as f32 - hot;
for col in lo..=hi {
let dx = col as f32 - hot;
let d = cross_distance(dx, dy, arm, 0.0);
let i = row as usize * side + col as usize;
if d < dist[i] {
dist[i] = d;
}
}
}
}
/// One bitmap pixel from its distance to the stroke centre, in straight RGBA (winit form).
fn push_stroke_pixel(rgba: &mut Vec<u8>, distance: f32, scale: f32, dark: egui::Color32, light: egui::Color32) {
let (fill, outline) = stroke_coverage(distance, scale);
let a_dark = outline * f32::from(dark.a()) / 255.0;
let a_light = fill * f32::from(light.a()) / 255.0;
let alpha = a_light + a_dark * (1.0 - a_light);
let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 };
rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]);
}
fn rasterize(shape: &Shape, scale: f32) -> Option<CustomCursorImage> {
if !scale.is_finite() || scale <= 0.0 {
return None;
}
@ -228,6 +333,30 @@ fn rasterize(shape: Shape, scale: f32) -> Option<CustomCursorImage> {
let bytes = usize::from(side).checked_mul(usize::from(side))?.checked_mul(4)?;
let mut rgba = Vec::with_capacity(bytes);
let [dark, light] = crate::theme::Tokens::cursor_outline();
if let Shape::Outline { rings, centre } = shape {
// Splat each segment into its bounding box instead of measuring every ring per pixel:
// tip outlines may carry hundreds of points.
let hotf = f32::from(hot);
let n = usize::from(side);
let reach = 1.5 * scale + 2.0;
let mut dist = vec![f32::INFINITY; n * n];
for ring in rings.iter() {
let len = ring.len();
if len < 2 {
continue;
}
for (i, &a) in ring.iter().enumerate() {
splat_segment(&mut dist, n, hotf, a * scale, ring[(i + 1) % len] * scale, reach);
}
}
if *centre {
splat_cross(&mut dist, n, hotf, 3.0 * scale);
}
for &d in &dist {
push_stroke_pixel(&mut rgba, d, scale, dark, light);
}
return Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] });
}
for row in 0..side {
let dy = f32::from(row) - f32::from(hot);
for col in 0..side {

View File

@ -21,7 +21,7 @@ fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutp
fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() {
for scale in [1.0, 1.25, 1.5, 2.0, 3.0] {
let radius = 20.0;
let image = rasterize(Shape::Circle { radius, centre: false }, scale).unwrap();
let image = rasterize(&Shape::Circle { radius, centre: false }, scale).unwrap();
let [x, y] = image.hotspot;
assert_eq!(image.size, [2 * x + 1, 2 * y + 1]);
assert_eq!(image.rgba.len(), usize::from(image.size[0]).pow(2) * 4);
@ -38,13 +38,13 @@ fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() {
#[test]
fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() {
let ring = rasterize(Shape::Circle { radius: 20.0, centre: true }, 1.0).unwrap();
let ring = rasterize(&Shape::Circle { radius: 20.0, centre: true }, 1.0).unwrap();
assert!(alpha(&ring, ring.hotspot[0], ring.hotspot[1]) > 230);
let precise = rasterize(Shape::Crosshair { length: 8.0, gap: 2.0 }, 1.0).unwrap();
let precise = rasterize(&Shape::Crosshair { length: 8.0, gap: 2.0 }, 1.0).unwrap();
let [x, y] = precise.hotspot;
assert_eq!(alpha(&precise, x, y), 0);
assert!(alpha(&precise, x + 5, y) > 230);
let solid = rasterize(Shape::Crosshair { length: 6.0, gap: 0.0 }, 1.0).unwrap();
let solid = rasterize(&Shape::Crosshair { length: 6.0, gap: 0.0 }, 1.0).unwrap();
assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230);
}
@ -52,7 +52,7 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() {
/// larger one falls back to the painted outline. Windows takes far larger cursors.
#[test]
fn cursor_bitmaps_stay_within_the_platform_limit() {
let fits = |radius: f32| rasterize(Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]);
let fits = |radius: f32| rasterize(&Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]);
assert!(fits(100.0).is_some_and(|side| side <= 255));
assert_eq!(fits(200.0).is_some(), cfg!(target_os = "windows"));
assert!(fits(200.0).is_none_or(|side| side <= 2048));
@ -61,21 +61,21 @@ fn cursor_bitmaps_stay_within_the_platform_limit() {
#[test]
fn hostile_sizes_are_rejected_before_allocating() {
for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 2048.0] {
assert!(rasterize(Shape::Circle { radius, centre: false }, 1.0).is_none());
assert!(rasterize(&Shape::Circle { radius, centre: false }, 1.0).is_none());
}
for scale in [f32::NAN, f32::INFINITY, -1.0, 0.0, f32::MAX] {
assert!(rasterize(Shape::Circle { radius: 20.0, centre: false }, scale).is_none());
assert!(rasterize(&Shape::Circle { radius: 20.0, centre: false }, scale).is_none());
}
for (length, gap) in [(1.0, 2.0), (8.0, -1.0), (f32::NAN, 0.0), (8.0, f32::NAN)] {
assert!(rasterize(Shape::Crosshair { length, gap }, 1.0).is_none());
assert!(rasterize(&Shape::Crosshair { length, gap }, 1.0).is_none());
}
}
#[test]
fn hand_bitmaps_are_outlined_hands_around_a_centred_hotspot_at_each_dpi() {
for scale in [1.0, 1.25, 1.5, 2.0, 3.0] {
let open = rasterize(Shape::Hand { closed: false }, scale).unwrap();
let fist = rasterize(Shape::Hand { closed: true }, scale).unwrap();
let open = rasterize(&Shape::Hand { closed: false }, scale).unwrap();
let fist = rasterize(&Shape::Hand { closed: true }, scale).unwrap();
for image in [&open, &fist] {
let [x, y] = image.hotspot;
assert_eq!(image.size, [2 * x + 1, 2 * y + 1]);
@ -93,7 +93,7 @@ fn hand_bitmaps_are_outlined_hands_around_a_centred_hotspot_at_each_dpi() {
#[test]
fn hand_bitmap_is_a_straight_rgba_silhouette_outlined_outside_the_hand() {
let image = rasterize(Shape::Hand { closed: false }, 1.0).unwrap();
let image = rasterize(&Shape::Hand { closed: false }, 1.0).unwrap();
let side = usize::from(image.size[0]);
let px = |x: usize, y: usize| <[u8; 4]>::try_from(&image.rgba[(y * side + x) * 4..][..4]).unwrap();
let mid = side / 2;
@ -123,8 +123,8 @@ fn hand_bitmap_is_a_straight_rgba_silhouette_outlined_outside_the_hand() {
#[test]
fn hostile_scales_get_no_hand_bitmap() {
for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 500.0] {
assert!(rasterize(Shape::Hand { closed: false }, scale).is_none(), "{scale}");
assert!(rasterize(Shape::Hand { closed: true }, scale).is_none(), "{scale}");
assert!(rasterize(&Shape::Hand { closed: false }, scale).is_none(), "{scale}");
assert!(rasterize(&Shape::Hand { closed: true }, scale).is_none(), "{scale}");
}
}
@ -145,7 +145,7 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() {
ui.ctx().set_cursor_icon(icon);
});
let image = bitmap.platform_output.cursor_image.expect("Windows has no stock hand");
assert_eq!(image.rgba, rasterize(Shape::Hand { closed }, 1.0).unwrap().rgba);
assert_eq!(image.rgba, rasterize(&Shape::Hand { closed }, 1.0).unwrap().rgba);
assert_eq!(bitmap.platform_output.cursor_icon, CursorIcon::Crosshair, "visible fallback if the OS rejects a bitmap");
}
let open = hand_with(&ctx, false, true);
@ -157,13 +157,13 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() {
fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() {
let ctx = egui::Context::default();
let shape = Shape::Circle { radius: 20.0, centre: false };
let a = cached_image(&ctx, shape, 1.0).unwrap();
let b = cached_image(&ctx, shape, 1.0).unwrap();
let a = cached_image(&ctx, &shape, 1.0).unwrap();
let b = cached_image(&ctx, &shape, 1.0).unwrap();
assert!(Arc::ptr_eq(&a.rgba, &b.rgba));
let scaled = cached_image(&ctx, shape, 2.0).unwrap();
let scaled = cached_image(&ctx, &shape, 2.0).unwrap();
assert!(!Arc::ptr_eq(&a.rgba, &scaled.rgba));
assert!(scaled.size[0] > a.size[0]);
let larger = cached_image(&ctx, Shape::Circle { radius: 40.0, centre: false }, 2.0).unwrap();
let larger = cached_image(&ctx, &Shape::Circle { radius: 40.0, centre: false }, 2.0).unwrap();
assert!(larger.size[0] > scaled.size[0]);
}
@ -171,7 +171,7 @@ fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() {
fn lifecycle_clears_sticky_images_and_respects_later_widgets() {
let ctx = egui::Context::default();
ctx.add_plugin(CursorLifecycle);
let image = rasterize(Shape::Circle { radius: 20.0, centre: false }, 1.0).unwrap();
let image = rasterize(&Shape::Circle { radius: 20.0, centre: false }, 1.0).unwrap();
let output = frame(&ctx, |ui| {
ui.ctx().set_cursor_image(Some(image.clone()));
ui.ctx().set_cursor_icon(CursorIcon::None);
@ -233,8 +233,8 @@ fn write_cursor_preview() {
rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(background);
}
}
for (row, &(shape, scale)) in cases.iter().enumerate() {
let image = rasterize(shape, scale).unwrap();
for (row, (shape, scale)) in cases.iter().enumerate() {
let image = rasterize(shape, *scale).unwrap();
let side = usize::from(image.size[0]);
let x0 = col * 200 + 100 - usize::from(image.hotspot[0]);
let y0 = row * 100 + 50 - usize::from(image.hotspot[1]);
@ -306,7 +306,7 @@ fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_chan
let hand = h.output().platform_output.cursor_image.clone();
if cfg!(target_os = "windows") {
let hand = hand.expect("the Hand's open hand reaches the OS as a bitmap");
assert_eq!(hand.rgba, rasterize(Shape::Hand { closed: false }, h.ctx.pixels_per_point()).unwrap().rgba);
assert_eq!(hand.rgba, rasterize(&Shape::Hand { closed: false }, h.ctx.pixels_per_point()).unwrap().rgba);
} else {
assert!(hand.is_none(), "the OS draws the hand");
assert_eq!(h.output().platform_output.cursor_icon, CursorIcon::Grab);