mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
# Conflicts: # crates/ui-egui/src/canvas.rs # docs/ui-parity.md
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
//! Marching ants: the selection outline, drawn the way Photoshop draws it (#7489).
|
||||
//!
|
||||
//! Everything is laid out in device pixels, not egui points. An edge is a band `width` pixels
|
||||
//! thick on the pixel boundary nearest to it (one pixel at 100 %, two up to 200 %, …), and each
|
||||
//! of its pixels is black or white by a dash pattern fixed to the screen: dashes and gaps of
|
||||
//! `4 · width` pixels along the edge, each row of a thicker band shifted `width` pixels against
|
||||
//! the row beside it, so a dash ends on a slant. The pattern slides over time (the march).
|
||||
//!
|
||||
//! A point is a fractional number of pixels on a scaled display (125 %, 150 %), so the ants used to
|
||||
//! be snapped to a grid the screen doesn't have: one edge of a rectangle came out 2 px thick and the
|
||||
//! next 1 px, the dashes 5 px long here and 4 px there.
|
||||
//!
|
||||
//! Horizontal and vertical edges go in one mesh of pixel-aligned quads, which egui draws as it is:
|
||||
//! a line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen whole
|
||||
//! (about 10⁵ segments, #3846). A slanted edge (a transformed outline, an ellipse, a lasso path)
|
||||
//! stays a line shape, which egui antialiases.
|
||||
|
||||
use egui::{Color32, Mesh, Pos2, Rect, Shape, Stroke, pos2};
|
||||
|
||||
/// Dash (and gap) length, in line widths.
|
||||
const DASH: i64 = 4;
|
||||
/// How fast the ants march, in line widths per second.
|
||||
const SPEED: f64 = 10.0;
|
||||
/// Longer than any line that fits the view: only its part in view is dashed.
|
||||
const LONG: f32 = 8192.0;
|
||||
|
||||
/// The ants on a display of `ppp` device pixels per point, at one moment of the march.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Style {
|
||||
ppp: f32,
|
||||
/// Line thickness, in device pixels.
|
||||
width: i64,
|
||||
/// Dash length, in device pixels.
|
||||
dash: i64,
|
||||
/// How far the pattern has marched, in device pixels.
|
||||
phase: i64,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// The ants at frame time `time` (s).
|
||||
pub(crate) fn new(ppp: f32, time: f64) -> Self {
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
// A hair over a whole scale (a windowing system's rounding) is still that scale.
|
||||
let width = ((ppp - 0.01).ceil() as i64).clamp(1, 8);
|
||||
let dash = DASH * width;
|
||||
let time = if time.is_finite() { time.max(0.0) } else { 0.0 };
|
||||
let phase = ((time * SPEED * width as f64) % (2 * dash) as f64) as i64;
|
||||
Self { ppp, width, dash, phase }
|
||||
}
|
||||
|
||||
/// The black runs of `[lo, hi)` along one pixel row (or column) of a band. A pixel is black
|
||||
/// where `(s + phase) mod 2·dash < dash`, `s` being its position along the edge plus `off`,
|
||||
/// `width` times its position across it: `x + w·y` on a horizontal edge, `y + w·x` on a
|
||||
/// vertical one.
|
||||
fn runs(&self, lo: i64, hi: i64, off: i64) -> impl Iterator<Item = (i64, i64)> + '_ {
|
||||
let period = 2 * self.dash;
|
||||
let first = lo - (lo + off + self.phase).rem_euclid(period);
|
||||
std::iter::successors(Some(first), move |s| Some(s + period))
|
||||
.take_while(move |&s| s < hi)
|
||||
.map(move |s| (s.max(lo), (s + self.dash).min(hi)))
|
||||
.filter(|(s, e)| e > s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The ants along `lines` (screen points) inside `clip`: a mesh of the horizontal and vertical
|
||||
/// edges, and line shapes for the slanted ones. `snap_slanted` puts a slanted edge's ends on pixel
|
||||
/// centres too: a traced outline in a rotated view, a staircase whose steps must meet. A drawn curve
|
||||
/// (an ellipse, a lasso path) keeps its own points, or its nearly flat arcs turn into 1 px stairs
|
||||
/// whose antialiased steps wash out to grey.
|
||||
pub(crate) fn shapes(lines: impl Iterator<Item = (Pos2, Pos2)>, clip: Rect, style: Style, snap_slanted: bool) -> (Mesh, Vec<Shape>) {
|
||||
let Style { ppp, width: w, dash, .. } = style;
|
||||
let mut mesh = Mesh::default();
|
||||
let mut slanted = Vec::new();
|
||||
// Device pixels → a rectangle in points.
|
||||
let mut quad = |x0: i64, y0: i64, x1: i64, y1: i64, color: Color32| {
|
||||
let r = Rect::from_min_max(pos2(x0 as f32 / ppp, y0 as f32 / ppp), pos2(x1 as f32 / ppp, y1 as f32 / ppp));
|
||||
mesh.add_colored_rect(r, color);
|
||||
};
|
||||
// The view in device pixels, a dash period wider: a band is cut to it so a long edge costs
|
||||
// what its visible part does, and the pattern is fixed to the screen so the cut doesn't move it.
|
||||
let view = clip.expand(2.0 * dash as f32 / ppp);
|
||||
let (vx0, vy0) = ((view.min.x * ppp).floor() as i64, (view.min.y * ppp).floor() as i64);
|
||||
let (vx1, vy1) = ((view.max.x * ppp).ceil() as i64, (view.max.y * ppp).ceil() as i64);
|
||||
// The first pixel of a band across its boundary: centred on it, the odd one after it.
|
||||
let half = w / 2;
|
||||
for (pa, pb) in lines {
|
||||
// A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon).
|
||||
if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) {
|
||||
continue;
|
||||
}
|
||||
let (pa, pb) = if pa.distance(pb) > LONG {
|
||||
let Some(inside) = crate::canvas::clip_line(view, pa, pb) else { continue };
|
||||
inside
|
||||
} else {
|
||||
(pa, pb)
|
||||
};
|
||||
let (da, db) = (pa * ppp, pb * ppp);
|
||||
let (ax, ay, bx, by) = (da.x.round() as i64, da.y.round() as i64, db.x.round() as i64, db.y.round() as i64);
|
||||
if (ax, ay) == (bx, by) {
|
||||
continue;
|
||||
}
|
||||
if ay == by {
|
||||
// Horizontal: rows `y0..y0 + w`, from one end's band to the other's, so edges meet
|
||||
// at corners.
|
||||
let (x0, x1) = ((ax.min(bx) - half).max(vx0), (ax.max(bx) - half + w).min(vx1));
|
||||
let y0 = ay - half;
|
||||
if x1 <= x0 {
|
||||
continue;
|
||||
}
|
||||
quad(x0, y0, x1, y0 + w, Color32::WHITE);
|
||||
for y in y0..y0 + w {
|
||||
for (s, e) in style.runs(x0, x1, w * y) {
|
||||
quad(s, y, e, y + 1, Color32::BLACK);
|
||||
}
|
||||
}
|
||||
} else if ax == bx {
|
||||
let (y0, y1) = ((ay.min(by) - half).max(vy0), (ay.max(by) - half + w).min(vy1));
|
||||
let x0 = ax - half;
|
||||
if y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
quad(x0, y0, x0 + w, y1, Color32::WHITE);
|
||||
for x in x0..x0 + w {
|
||||
for (s, e) in style.runs(y0, y1, w * x) {
|
||||
quad(x, s, x + 1, e, Color32::BLACK);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// On the middle of the band a snapped straight edge would be.
|
||||
let centre = w as f32 / 2.0 - half as f32;
|
||||
let (da, db) = if snap_slanted { (pos2(ax as f32 + centre, ay as f32 + centre), pos2(bx as f32 + centre, by as f32 + centre)) } else { (da, db) };
|
||||
let len = da.distance(db);
|
||||
let dir = (db - da) / len;
|
||||
let stroke = |color| Stroke::new(w as f32 / ppp, color);
|
||||
slanted.push(Shape::line_segment([da / ppp, db / ppp], stroke(Color32::WHITE)));
|
||||
// Phased by screen position, like the straight edges, so dashes line up across joins.
|
||||
let period = (2 * dash) as f32;
|
||||
let mut t = -(da.x.floor() + da.y.floor() + style.phase as f32).rem_euclid(period);
|
||||
while t < len {
|
||||
let (s0, s1) = (t.max(0.0), (t + dash as f32).min(len));
|
||||
if s1 > s0 {
|
||||
slanted.push(Shape::line_segment([(da + dir * s0) / ppp, (da + dir * s1) / ppp], stroke(Color32::BLACK)));
|
||||
}
|
||||
t += period;
|
||||
}
|
||||
}
|
||||
}
|
||||
(mesh, slanted)
|
||||
}
|
||||
|
||||
/// Draw the ants along `lines` (screen points) at frame time `time`; see [`shapes`].
|
||||
pub(crate) fn draw(painter: &egui::Painter, lines: impl Iterator<Item = (Pos2, Pos2)>, time: f64, snap_slanted: bool) {
|
||||
let style = Style::new(painter.pixels_per_point(), time);
|
||||
let (mesh, slanted) = shapes(lines, painter.clip_rect(), style, snap_slanted);
|
||||
painter.extend(slanted);
|
||||
if !mesh.is_empty() {
|
||||
painter.add(Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments of the polyline `points`, back to the first when `closed`.
|
||||
pub(crate) fn polyline(points: &[Pos2], closed: bool) -> impl Iterator<Item = (Pos2, Pos2)> + '_ {
|
||||
let back = points.first().zip(points.last()).filter(|_| closed && points.len() > 2).map(|(f, l)| (*l, *f));
|
||||
points.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).chain(back)
|
||||
}
|
||||
|
||||
/// Draw animated ants along the polyline `points` (screen points), and keep them marching.
|
||||
pub(crate) fn draw_polyline(painter: &egui::Painter, points: &[Pos2], closed: bool) {
|
||||
let time = painter.ctx().input(|i| i.time);
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
draw(painter, polyline(points, closed), time, false);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VIEW: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(800.0, 600.0) };
|
||||
|
||||
/// The mesh's quads in device pixels, `[x0, y0, x1, y1, black]`, checking each lands exactly
|
||||
/// on the pixel grid.
|
||||
fn quads(mesh: &Mesh, ppp: f32) -> Vec<[i64; 5]> {
|
||||
mesh.vertices
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let px: Vec<[f32; 2]> = q.iter().map(|v| [v.pos.x * ppp, v.pos.y * ppp]).collect();
|
||||
for p in &px {
|
||||
assert!((p[0] - p[0].round()).abs() < 1e-3 && (p[1] - p[1].round()).abs() < 1e-3, "off the pixel grid at {ppp}x: {p:?}");
|
||||
}
|
||||
let xs = px.iter().map(|p| p[0].round() as i64);
|
||||
let ys = px.iter().map(|p| p[1].round() as i64);
|
||||
let black = q[0].color == Color32::BLACK;
|
||||
[xs.clone().min().unwrap(), ys.clone().min().unwrap(), xs.max().unwrap(), ys.max().unwrap(), i64::from(black)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The black pixels of a rasterised mesh.
|
||||
fn black_pixels(q: &[[i64; 5]]) -> std::collections::BTreeSet<(i64, i64)> {
|
||||
q.iter().filter(|q| q[4] == 1).flat_map(|&[x0, y0, x1, y1, _]| (x0..x1).flat_map(move |x| (y0..y1).map(move |y| (x, y)))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_is_whole_device_pixels_thick() {
|
||||
for (ppp, w) in [(1.0, 1), (1.0001, 1), (1.25, 2), (1.5, 2), (1.75, 2), (2.0, 2), (2.5, 3), (3.0, 3), (0.0, 1), (f32::NAN, 1)] {
|
||||
assert_eq!(Style::new(ppp, 0.0).width, w, "@{ppp}x");
|
||||
assert_eq!(Style::new(ppp, 0.0).dash, 4 * w);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bug in #7489: at 125 % one edge was 2 px and the next 1 px.
|
||||
#[test]
|
||||
fn every_edge_of_a_rectangle_is_as_thick_as_the_others_at_a_fractional_scale() {
|
||||
for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] {
|
||||
let style = Style::new(ppp, 0.0);
|
||||
// Corners at odd and even points, which a fractional scale puts at different fractions
|
||||
// of a pixel.
|
||||
let r = [pos2(101.0, 60.0), pos2(203.0, 60.0), pos2(203.0, 241.0), pos2(101.0, 241.0)];
|
||||
let (mesh, slanted) = shapes(polyline(&r, true), VIEW, style, true);
|
||||
assert!(slanted.is_empty());
|
||||
let q = quads(&mesh, ppp);
|
||||
let w = style.width;
|
||||
// Every white base is `w` thick, and every dash is one pixel row (or column) of it.
|
||||
for &[x0, y0, x1, y1, black] in &q {
|
||||
let across = (x1 - x0).min(y1 - y0);
|
||||
assert_eq!(across, if black == 1 { 1 } else { w }, "@{ppp}x: {:?}", [x0, y0, x1, y1]);
|
||||
}
|
||||
// Dashes are 4·w long, but where the edge's ends cut them.
|
||||
let dashes: Vec<i64> = q.iter().filter(|q| q[4] == 1).map(|&[x0, y0, x1, y1, _]| (x1 - x0).max(y1 - y0)).collect();
|
||||
assert!(dashes.iter().all(|&n| n <= 4 * w) && dashes.iter().filter(|&&n| n == 4 * w).count() * 2 > dashes.len(), "@{ppp}x: {dashes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2 px line's rows are shifted 2 px against each other, so a dash ends on a slant (as in
|
||||
/// Photoshop: the row nearer the bottom or right leads).
|
||||
#[test]
|
||||
fn a_thick_line_ends_its_dashes_on_a_slant() {
|
||||
let style = Style::new(2.0, 0.0);
|
||||
let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(90.0, 10.0)], false), VIEW, style, true);
|
||||
let black = black_pixels(&quads(&mesh, 2.0));
|
||||
// Device row 19 and 20 straddle the edge at y = 20 px.
|
||||
let row = |y: i64| -> Vec<i64> { (30..170).filter(|&x| black.contains(&(x, y))).collect() };
|
||||
let (upper, lower) = (row(19), row(20));
|
||||
assert_eq!(upper.len(), lower.len());
|
||||
let shifted: Vec<i64> = upper.iter().map(|x| x - 2).filter(|x| (30..168).contains(x)).collect();
|
||||
assert!(shifted.iter().all(|x| lower.contains(x)), "the lower row leads by 2 px: {upper:?} / {lower:?}");
|
||||
// The vertical edges too: the right column leads (starts higher) by 2 px.
|
||||
let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(10.0, 90.0)], false), VIEW, style, true);
|
||||
let black = black_pixels(&quads(&mesh, 2.0));
|
||||
let col = |x: i64| -> Vec<i64> { (30..170).filter(|&y| black.contains(&(x, y))).collect() };
|
||||
let (left, right) = (col(19), col(20));
|
||||
let shifted: Vec<i64> = left.iter().map(|y| y - 2).filter(|y| (30..168).contains(y)).collect();
|
||||
assert!(shifted.iter().all(|y| right.contains(y)), "{left:?} / {right:?}");
|
||||
}
|
||||
|
||||
/// At 100 % the pattern is the classic one: black where `(x + y + phase) mod 8 < 4`, so
|
||||
/// dashes line up across a corner and march with time.
|
||||
#[test]
|
||||
fn the_ants_march_and_line_up_at_corners() {
|
||||
let at = |time: f64| {
|
||||
let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(50.0, 10.0), pos2(50.0, 50.0)], false), VIEW, Style::new(1.0, time), true);
|
||||
black_pixels(&quads(&mesh, 1.0))
|
||||
};
|
||||
let still = at(0.0);
|
||||
for x in 10..=50 {
|
||||
assert_eq!(still.contains(&(x, 10)), (x + 10).rem_euclid(8) < 4, "top edge at {x}");
|
||||
}
|
||||
for y in 10..=50 {
|
||||
assert_eq!(still.contains(&(50, y)), (50 + y).rem_euclid(8) < 4, "right edge at {y}");
|
||||
}
|
||||
assert_ne!(still, at(0.25), "the ants march");
|
||||
assert_eq!(still, at(0.8), "one period later they are back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_edge_costs_only_its_visible_part() {
|
||||
let (mesh, _) = shapes(polyline(&[pos2(-1e7, 100.0), pos2(1e7, 100.0)], false), VIEW, Style::new(1.0, 0.0), true);
|
||||
// 800 px in view: ~100 dashes, not 2.5 million.
|
||||
assert!(mesh.vertices.len() < 4 * 200, "{} vertices", mesh.vertices.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slanted_lines_are_antialiased_strokes_as_thick_as_the_straight_ones() {
|
||||
let style = Style::new(1.5, 0.0);
|
||||
let (mesh, slanted) = shapes(polyline(&[pos2(10.0, 10.0), pos2(80.0, 50.0)], false), VIEW, style, false);
|
||||
assert!(mesh.is_empty());
|
||||
assert!(slanted.len() > 2);
|
||||
for s in &slanted {
|
||||
let Shape::LineSegment { stroke, .. } = s else { panic!("{s:?}") };
|
||||
assert!((stroke.width * 1.5 - 2.0).abs() < 1e-4, "2 device px: {}", stroke.width);
|
||||
}
|
||||
let colours: Vec<Color32> = slanted.iter().filter_map(|s| if let Shape::LineSegment { stroke, .. } = s { Some(stroke.color) } else { None }).collect();
|
||||
assert!(colours.contains(&Color32::WHITE) && colours.contains(&Color32::BLACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_points_draw_nothing_and_never_panic() {
|
||||
for (a, b) in [
|
||||
(pos2(f32::NAN, 0.0), pos2(10.0, 10.0)),
|
||||
(pos2(f32::INFINITY, 0.0), pos2(10.0, 10.0)),
|
||||
(pos2(5.0, 5.0), pos2(5.0, 5.0)),
|
||||
(pos2(-1e30, -1e30), pos2(-1e30, 1e30)),
|
||||
(pos2(1e30, 5.0), pos2(-1e30, 5.0)),
|
||||
] {
|
||||
for snap in [true, false] {
|
||||
let _ = shapes([(a, b)].into_iter(), VIEW, Style::new(1.25, f64::NAN), snap);
|
||||
}
|
||||
}
|
||||
assert_eq!(polyline(&[], true).count(), 0);
|
||||
assert_eq!(polyline(&[pos2(0.0, 0.0)], true).count(), 0);
|
||||
assert_eq!(polyline(&[pos2(0.0, 0.0), pos2(1.0, 0.0)], true).count(), 1);
|
||||
assert_eq!(polyline(&[pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)], true).count(), 3);
|
||||
}
|
||||
}
|
||||
+14
-101
@@ -442,23 +442,20 @@ fn single_line_drag_px(app: &PhotocraftApp, d: &Drag) -> Option<[f64; 4]> {
|
||||
///
|
||||
/// A rotated view projects the rectangle onto a rotated one, and an axis-aligned screen box
|
||||
/// through two of its corners is a different shape in a different place: the commit was always
|
||||
/// right, only the hinting was drawn elsewhere (#2512). Every point is snapped to the half-pixel
|
||||
/// grid like every other ants outline (`marching_ants_segments`), so the lines stay crisp at any
|
||||
/// zoom (#172).
|
||||
/// right, only the hinting was drawn elsewhere (#2512). The points are not snapped: the ants snap
|
||||
/// straight edges to device pixels as they draw them (`crate::ants`), like every other outline, so
|
||||
/// the lines stay crisp at any zoom (#172) and display scale (#7489).
|
||||
pub(crate) fn marquee_ants(xf: &ViewXform, tool: Tool, a: [f64; 2], b: [f64; 2]) -> Vec<Pos2> {
|
||||
let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0]));
|
||||
let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1]));
|
||||
let at = |x: f64, y: f64| {
|
||||
let p = xf.to_screen(x as f32, y as f32);
|
||||
pos2(p.x.round() + 0.5, p.y.round() + 0.5)
|
||||
};
|
||||
let at = |x: f64, y: f64| xf.to_screen(x as f32, y as f32);
|
||||
if tool != Tool::EllipseMarquee {
|
||||
return vec![at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)];
|
||||
}
|
||||
// An axis-aligned view (rotation a multiple of 90 degrees) maps the ellipse onto the one
|
||||
// inscribed in the snapped screen box of two opposite corners. Draw that, unsnapped: snapping
|
||||
// each vertex on its own turns the nearly flat arcs at the edge midpoints into 1 px stairs,
|
||||
// whose antialiased steps wash the ants out to grey on white and black.
|
||||
// inscribed in the screen box of two opposite corners. The ants draw its vertices where they
|
||||
// are: snapping each on its own turns the nearly flat arcs at the edge midpoints into 1 px
|
||||
// stairs, whose antialiased steps wash the ants out to grey on white and black.
|
||||
let quarter = xf.rotation.rem_euclid(90.0);
|
||||
if quarter < 1e-4 || 90.0 - quarter < 1e-4 {
|
||||
let r = Rect::from_two_pos(at(x0, y0), at(x1, y1));
|
||||
@@ -4965,7 +4962,7 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
|
||||
}
|
||||
|
||||
/// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any.
|
||||
fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> {
|
||||
pub(crate) fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> {
|
||||
// Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation
|
||||
// loses viewport-sized differences there, clipping visible ants to the wrong endpoints.
|
||||
let (ax, ay) = (f64::from(a.x), f64::from(a.y));
|
||||
@@ -5005,94 +5002,10 @@ mod transform_outline_clip_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marching ants along lines already placed on screen (a warped outline is a run of short ones).
|
||||
/// Marching ants along lines already placed on screen (a warped outline is a run of short ones),
|
||||
/// in device pixels like Photoshop's (`crate::ants`, #7489).
|
||||
pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator<Item = (Pos2, Pos2)>, time: f64) {
|
||||
// Longer than any line that fits the view: only its part in view is dashed.
|
||||
const LONG: f32 = 8192.0;
|
||||
let dash = 4.0f32;
|
||||
let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32;
|
||||
let clip = painter.clip_rect();
|
||||
// Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a
|
||||
// line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen
|
||||
// whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted
|
||||
// segment (a transformed outline) stays a line shape, which egui antialiases.
|
||||
let mut mesh = egui::Mesh::default();
|
||||
// A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line).
|
||||
let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| {
|
||||
let i = mesh.vertices.len() as u32;
|
||||
for p in [a + side, a - side, b - side, b + side] {
|
||||
mesh.colored_vertex(p, color);
|
||||
}
|
||||
mesh.add_triangle(i, i + 1, i + 2);
|
||||
mesh.add_triangle(i, i + 2, i + 3);
|
||||
};
|
||||
for (pa, pb) in lines {
|
||||
// A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon).
|
||||
if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) {
|
||||
continue;
|
||||
}
|
||||
let (pa, pb) = if pa.distance(pb) > LONG {
|
||||
let Some(inside) = clip_line(clip.expand(4.0 * dash), pa, pb) else { continue };
|
||||
inside
|
||||
} else {
|
||||
(pa, pb)
|
||||
};
|
||||
let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5);
|
||||
let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5);
|
||||
let len = pa.distance(pb);
|
||||
if len < 0.5 {
|
||||
continue;
|
||||
}
|
||||
let dir = (pb - pa) / len;
|
||||
let side = dir.rot90() * 0.5;
|
||||
let straight = pa.x == pb.x || pa.y == pb.y;
|
||||
if straight {
|
||||
// The white base runs half a pixel past both ends, so joined segments meet at corners.
|
||||
quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE);
|
||||
} else {
|
||||
painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
// Phase by screen position so dashes line up across joined segments.
|
||||
let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0);
|
||||
let mut t = -start;
|
||||
while t < len {
|
||||
let s0 = t.max(0.0);
|
||||
let s1 = (t + dash).min(len);
|
||||
if s1 > s0 {
|
||||
if straight {
|
||||
quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK);
|
||||
} else {
|
||||
painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
}
|
||||
t += dash * 2.0;
|
||||
}
|
||||
}
|
||||
if !mesh.is_empty() {
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn marching_ants(painter: &egui::Painter, r: Rect, time: f64) {
|
||||
let dash = 4.0;
|
||||
let offset = ((time * 8.0) % (dash as f64 * 2.0)) as f32;
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
|
||||
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let len = a.distance(b);
|
||||
let dir = (b - a) / len.max(1e-3);
|
||||
let mut t = -offset;
|
||||
while t < len {
|
||||
let s = t.max(0.0);
|
||||
let e = (t + dash).min(len);
|
||||
if e > s {
|
||||
painter.line_segment([a + dir * s, a + dir * e], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
t += dash * 2.0;
|
||||
}
|
||||
}
|
||||
crate::ants::draw(painter, lines, time, true);
|
||||
}
|
||||
|
||||
/// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright
|
||||
@@ -5647,12 +5560,12 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
|
||||
if let Some(lasso) = &d.lasso {
|
||||
pts.push(xf.to_screen(lasso.cursor[0] as f32, lasso.cursor[1] as f32));
|
||||
}
|
||||
// On pixel centres like the marquee's ants, so a 1 pt line stays crisp wherever the
|
||||
// canvas sits (a pixel edge would blur it to grey).
|
||||
let pts: Vec<Pos2> = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect();
|
||||
// The Lasso's outline is solid until the release makes the selection (#7495); a Patch
|
||||
// or Content-Aware Move area keeps its ants.
|
||||
if d.tool == Tool::Lasso {
|
||||
// On pixel centres, so a 1 pt line stays crisp wherever the canvas sits (a pixel
|
||||
// edge would blur it to grey).
|
||||
let pts: Vec<Pos2> = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect();
|
||||
crate::tool_feedback::draw_solid_path(painter, &pts, false);
|
||||
} else {
|
||||
crate::tool_feedback::draw_ants(painter, &pts, false);
|
||||
|
||||
@@ -23,6 +23,7 @@ mod align_bar;
|
||||
mod alt_grab;
|
||||
pub mod analysis_ui;
|
||||
pub mod anchor_tools;
|
||||
mod ants;
|
||||
pub mod artboard_tool;
|
||||
pub mod artboard_ui;
|
||||
pub(crate) mod background_redraw;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! ⌥ draws from the centre (both together work too), a "W × H px" readout follows the cursor, and
|
||||
//! modifiers held before the drag still pick add/subtract (#188) instead of constraining.
|
||||
|
||||
use egui::{Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui::{Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_geom::Rect;
|
||||
@@ -438,8 +438,7 @@ fn the_live_outline_matches_the_committed_selection_in_a_rotated_view() {
|
||||
if tool == Tool::RectMarquee {
|
||||
// Each corner is a corner of the dragged rectangle, mapped: an axis-aligned box
|
||||
// through the two diagonal corners puts all four elsewhere.
|
||||
let snap = |p: Pos2| pos2(p.x.round() + 0.5, p.y.round() + 0.5);
|
||||
let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| snap(xf.to_screen(x as f32, y as f32)));
|
||||
let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| xf.to_screen(x as f32, y as f32));
|
||||
for (i, (p, w)) in pts.iter().zip(want).enumerate() {
|
||||
assert!((p.x - w.x).abs() < 0.01 && (p.y - w.y).abs() < 0.01, "{tool:?}: corner {i} at {p:?}, the dragged rectangle's corner is at {w:?}");
|
||||
}
|
||||
|
||||
@@ -168,53 +168,10 @@ pub fn draw_badge(painter: &egui::Painter, p: Pos2, b: Badge, centred: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dash length of the marching ants, in screen points.
|
||||
pub const DASH: f32 = 4.0;
|
||||
|
||||
/// Marching ants along a polyline: a solid white line under black dashes, the dashes shifted by
|
||||
/// `phase` (animate it with time). Visible on any image, like Photoshop's selection edges.
|
||||
pub fn ants(points: &[Pos2], closed: bool, phase: f32) -> Vec<Shape> {
|
||||
let Some(&first) = points.first().filter(|_| points.len() >= 2) else { return Vec::new() };
|
||||
let mut pts = points.to_vec();
|
||||
if closed {
|
||||
pts.push(first);
|
||||
}
|
||||
let mut out = vec![Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))];
|
||||
let black = Stroke::new(1.0, Color32::BLACK);
|
||||
// Arc length so dashes run continuously across vertices.
|
||||
let mut along = -phase.rem_euclid(DASH * 2.0);
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let len = a.distance(b);
|
||||
if len < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let dir = (b - a) / len;
|
||||
// Position of this segment's start within the dash period.
|
||||
let mut t = -(along.rem_euclid(DASH * 2.0));
|
||||
while t < len {
|
||||
let (s0, s1) = (t.max(0.0), (t + DASH).min(len));
|
||||
if s1 > s0 {
|
||||
out.push(Shape::line_segment([a + dir * s0, a + dir * s1], black));
|
||||
}
|
||||
t += DASH * 2.0;
|
||||
}
|
||||
along += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Marching-ants phase for the frame time `time` (s), and keep the animation running.
|
||||
pub fn ants_phase(ctx: &egui::Context) -> f32 {
|
||||
let time = ctx.input(|i| i.time);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
((time * 10.0) % (DASH as f64 * 2.0)) as f32
|
||||
}
|
||||
|
||||
/// Draw animated marching ants along `points`.
|
||||
/// Draw animated marching ants along `points`: the same ants as a selection's (`crate::ants`),
|
||||
/// visible on any image, like Photoshop's selection edges.
|
||||
pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) {
|
||||
let phase = ants_phase(painter.ctx());
|
||||
painter.extend(ants(points, closed, phase));
|
||||
crate::ants::draw_polyline(painter, points, closed);
|
||||
}
|
||||
|
||||
/// A selection outline still being drawn (Lasso, Polygonal Lasso, Magnetic Lasso): a solid line,
|
||||
@@ -321,23 +278,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ants_use_two_contrasting_colours_and_march() {
|
||||
fn shape_previews_contrast_and_ellipses_close() {
|
||||
let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)];
|
||||
let a = ants(&pts, true, 0.0);
|
||||
let cols = strokes(&a);
|
||||
assert!(cols.contains(&Color32::WHITE) && cols.contains(&Color32::BLACK), "{cols:?}");
|
||||
// Roughly half the length is black dashes: (40 + 30 + 50) / 8 ≈ 15 dashes.
|
||||
let dashes = cols.iter().filter(|c| **c == Color32::BLACK).count();
|
||||
assert!((13..=18).contains(&dashes), "{dashes}");
|
||||
// The phase moves the dashes (the ants march).
|
||||
let first = |s: &[Shape]| {
|
||||
s.iter().find_map(|s| match s {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == Color32::BLACK => Some(points[0]),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_ne!(first(&a), first(&ants(&pts, true, 2.0)));
|
||||
assert!(ants(&pts[..1], false, 0.0).is_empty());
|
||||
// Shape previews: the accent path over a dark halo.
|
||||
let s = contrast_path(pts.to_vec(), true, Color32::from_rgb(0, 120, 255));
|
||||
let cols = strokes(&s);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! #7489: the marching ants are drawn in device pixels, the same on every edge, at any display
|
||||
//! scale, as Photoshop draws them.
|
||||
//!
|
||||
//! The ants were snapped to the middle of an egui point and drawn one point wide. At 125 % or
|
||||
//! 150 % a point is not a whole number of pixels, so one edge of a rectangle came out 2 px wide
|
||||
//! and the next 1 px, and the dashes 5 px long here and 4 px there. Photoshop draws them on the
|
||||
//! screen's own pixels: black and white dashes, every edge as thick as every other, thicker on a
|
||||
//! scaled display, the dash pattern running diagonally across the screen so a 2 px line's dashes
|
||||
//! end on a slant.
|
||||
//!
|
||||
//! These tests render a committed rectangular selection with the real app offscreen on wgpu and
|
||||
//! read the pixels along each edge. They skip when no GPU adapter exists.
|
||||
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::ViewXform;
|
||||
use photocraft_ui_egui::control::{ControlRequest, handle};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
/// A rendered frame: RGBA8, physical pixels.
|
||||
struct Image {
|
||||
w: u32,
|
||||
h: u32,
|
||||
px: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Luma of pixel `(x, y)`, or `None` off the frame.
|
||||
fn luma(&self, x: i64, y: i64) -> Option<u8> {
|
||||
if x < 0 || y < 0 || x >= i64::from(self.w) || y >= i64::from(self.h) {
|
||||
return None;
|
||||
}
|
||||
let i = (y as usize * self.w as usize + x as usize) * 4;
|
||||
let p = self.px.get(i..i + 3)?;
|
||||
Some(((u16::from(p[0]) * 3 + u16::from(p[1]) * 6 + u16::from(p[2])) / 10) as u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// The app on an offscreen wgpu harness, or `None` when no GPU adapter exists. Only a panic
|
||||
/// before the app callback runs (creating the adapter) is a skip; one from the app's own setup
|
||||
/// fails the test rather than passing it untested.
|
||||
fn harness(ppp: f32) -> Option<Harness> {
|
||||
let started = Arc::new(AtomicBool::new(false));
|
||||
let started_in = Arc::clone(&started);
|
||||
let built = std::panic::catch_unwind(move || {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_pixels_per_point(ppp).with_max_steps(64).wgpu().build_eframe(move |cc| {
|
||||
started_in.store(true, Ordering::SeqCst);
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app
|
||||
})
|
||||
});
|
||||
match built {
|
||||
Ok(h) => Some(h),
|
||||
Err(payload) if started.load(Ordering::SeqCst) => std::panic::resume_unwind(payload),
|
||||
Err(_) => {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn control(h: &mut Harness, method: &str, params: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
handle(h.state_mut(), &ctx, &req);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// A fresh 400 × 300 document in `background` at 100 % with the rectangle `r` selected.
|
||||
fn setup(h: &mut Harness, background: &str, r: [i32; 4]) {
|
||||
{
|
||||
let app = h.state_mut();
|
||||
while app.session.active().is_some() {
|
||||
app.run("file.close", json!({"discard": true})).expect("close");
|
||||
}
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": background})).expect("new");
|
||||
app.sync_views();
|
||||
app.ui.extras.rulers = false;
|
||||
app.run("select.rect", json!({"x": r[0], "y": r[1], "width": r[2] - r[0], "height": r[3] - r[1]})).expect("select");
|
||||
}
|
||||
control(h, "ui.set", json!({"tool": "rectMarquee", "zoom": 1.0, "center": [200, 150]}));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn render(h: &mut Harness) -> Image {
|
||||
let img = h.render().expect("render");
|
||||
Image { w: img.width(), h: img.height(), px: img.into_raw() }
|
||||
}
|
||||
|
||||
/// Device pixel of document point `(x, y)`.
|
||||
fn device(h: &Harness, ppp: f32, x: f32, y: f32) -> (i64, i64) {
|
||||
let p = ViewXform::active(h.state()).expect("an active document view").to_screen(x, y) * ppp;
|
||||
(p.x.round() as i64, p.y.round() as i64)
|
||||
}
|
||||
|
||||
/// One edge of the outline as seen on screen: for every device pixel along its middle, the
|
||||
/// pixels across it (±6) that are dark.
|
||||
struct Edge {
|
||||
/// Dark pixels across the edge at each step along it.
|
||||
across: Vec<Vec<i64>>,
|
||||
/// Pixels in the band that are neither black nor white (antialiasing grey).
|
||||
greys: usize,
|
||||
}
|
||||
|
||||
/// Scan the edge from device pixel `a` to `b` (one of x or y shared), skipping 12 px at each end.
|
||||
fn scan(img: &Image, a: (i64, i64), b: (i64, i64)) -> Edge {
|
||||
let vertical = a.0 == b.0;
|
||||
let (lo, hi) = if vertical { (a.1.min(b.1), a.1.max(b.1)) } else { (a.0.min(b.0), a.0.max(b.0)) };
|
||||
let mut across = Vec::new();
|
||||
let mut greys = 0;
|
||||
for t in lo + 12..hi - 12 {
|
||||
let mut dark = Vec::new();
|
||||
for o in -6..=6 {
|
||||
let (x, y) = if vertical { (a.0 + o, t) } else { (t, a.1 + o) };
|
||||
let l = img.luma(x, y).unwrap_or(255);
|
||||
if l < 64 {
|
||||
dark.push(o);
|
||||
}
|
||||
if (24..232).contains(&l) {
|
||||
greys += 1;
|
||||
}
|
||||
}
|
||||
across.push(dark);
|
||||
}
|
||||
Edge { across, greys }
|
||||
}
|
||||
|
||||
/// The line is `w` px thick on every edge, crisp, with dashes of 4·w px.
|
||||
#[test]
|
||||
fn every_edge_is_equally_thick_at_any_display_scale() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
for ppp in [1.0f32, 1.25, 1.5, 2.0] {
|
||||
let Some(mut h) = harness(ppp) else { return };
|
||||
h.run_steps(4);
|
||||
// Edges at odd and even document columns, so a fractional scale lands them at different
|
||||
// fractions of a pixel.
|
||||
let r = [101, 60, 203, 241];
|
||||
setup(&mut h, "white", r);
|
||||
let img = render(&mut h);
|
||||
let w = ppp.ceil() as usize;
|
||||
let [x0, y0, x1, y1] = r.map(|v| v as f32);
|
||||
let (tl, tr, br, bl) = (device(&h, ppp, x0, y0), device(&h, ppp, x1, y0), device(&h, ppp, x1, y1), device(&h, ppp, x0, y1));
|
||||
let sides = [("top", (tl, tr)), ("right", (tr, br)), ("bottom", (bl, br)), ("left", (tl, bl))];
|
||||
let edges: Vec<(&str, Edge)> = sides.iter().map(|&(name, (a, b))| (name, scan(&img, a, b))).collect();
|
||||
// The bug: at 125 % one edge came out 2 px thick and the next 1 px.
|
||||
let thickest: Vec<(&str, usize)> = edges.iter().map(|(name, e)| (*name, e.across.iter().map(Vec::len).max().unwrap_or(0))).collect();
|
||||
assert!(thickest.iter().all(|t| t.1 == thickest[0].1), "@{ppp}x: every edge as thick as the others: {thickest:?}");
|
||||
for (name, e) in edges {
|
||||
assert_eq!(e.greys, 0, "@{ppp}x {name}: the ants are crisp black and white");
|
||||
let thick: Vec<usize> = e.across.iter().map(Vec::len).filter(|&n| n > 0).collect();
|
||||
assert!(!thick.is_empty(), "@{ppp}x {name}: the edge shows");
|
||||
// Where a dash covers every row across the line it is exactly `w` thick; at a dash's
|
||||
// slanted end only some of them are dark.
|
||||
let max = thick.iter().copied().max().unwrap_or(0);
|
||||
assert_eq!(max, w, "@{ppp}x {name}: {w} px thick, got {thick:?}");
|
||||
// Half the line is black: dashes and gaps of 4·w px each.
|
||||
let dark: usize = e.across.iter().map(Vec::len).sum();
|
||||
let frac = dark as f32 / (e.across.len() * w) as f32;
|
||||
assert!((0.45..=0.55).contains(&frac), "@{ppp}x {name}: {frac:.2} of the line is black");
|
||||
// Along any one row of the line, black runs are 4·w px long.
|
||||
let rows: std::collections::BTreeSet<i64> = e.across.iter().flatten().copied().collect();
|
||||
for row in rows {
|
||||
let on: Vec<bool> = e.across.iter().map(|d| d.contains(&row)).collect();
|
||||
let mut runs = Vec::new();
|
||||
let mut n = 0;
|
||||
for (i, &b) in on.iter().enumerate() {
|
||||
if b {
|
||||
n += 1;
|
||||
}
|
||||
if (!b || i + 1 == on.len()) && n > 0 {
|
||||
runs.push(n);
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
// The first and last run may be cut by the scan's ends.
|
||||
let inner = runs.get(1..runs.len().saturating_sub(1)).unwrap_or(&[]);
|
||||
assert!(inner.iter().all(|&n| n == 4 * w), "@{ppp}x {name} row {row}: dashes {runs:?}, want {} px", 4 * w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On black the white dashes show, between black ones.
|
||||
#[test]
|
||||
fn the_ants_alternate_black_and_white() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Some(mut h) = harness(1.0) else { return };
|
||||
h.run_steps(4);
|
||||
let r = [100, 60, 200, 240];
|
||||
setup(&mut h, "black", r);
|
||||
let img = render(&mut h);
|
||||
let (a, b) = (device(&h, 1.0, 100.0, 60.0), device(&h, 1.0, 200.0, 60.0));
|
||||
let mut bright = 0;
|
||||
let mut n = 0;
|
||||
for x in a.0 + 12..b.0 - 12 {
|
||||
n += 1;
|
||||
bright += usize::from((-2..=2).any(|o| img.luma(x, a.1 + o).is_some_and(|l| l > 230)));
|
||||
}
|
||||
let frac = bright as f32 / n as f32;
|
||||
assert!((0.4..=0.6).contains(&frac), "{frac:.2} of the top edge is white on black");
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
||||
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
||||
@@ -116,6 +116,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|
||||
| Single-pixel rows and columns kept on release | partial | #2391 |
|
||||
| Esc deselects / ⌘Z undoes the last selection | partial | #2674, #1432 |
|
||||
| Select › Modify live preview | missing | #1752 |
|
||||
| Marching ants as Photoshop draws them: device pixels, every edge equally thick (1 px at 100 %, 2 px up to 200 %), black/white dashes 4× the thickness, slanted dash ends, same look for drag previews and committed selections | done | `ants.rs`, `tests/marching_ants_pixels.rs` (#7489) |
|
||||
|
||||
## Pen, paths and shapes
|
||||
|
||||
@@ -285,5 +286,6 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| 2026-10-21 | minor | Crop rows corrected against the code and the scorecard: saved custom crop presets (TOOL-214-9, #2723) and Delete Cropped Pixels off are done (#6562); no percentage changed |
|
||||
| 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. |
|
||||
| 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) |
|
||||
| 2026-10-18 | minor | Marching ants drawn in device pixels like Photoshop's: equally thick on every edge at 125 %/150 % display scale, thicker on HiDPI, slanted dash ends (#7489) |
|
||||
| 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) |
|
||||
| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) |
|
||||
|
||||
Reference in New Issue
Block a user