collective

Browse / github.com/storytold/photocraft / patch

Pencil: keep a 1 px freehand line connected where it turns

[Bug Report] [PhotoCraft] Shift clicking 1px pencil issues ### Description There seem to be the problem with the shift-clicking endpoint, sometimes it doesn't match the grid click coordinates. PhotoCraft v0.11.1 The diagram below shows starting and ending points for pencil with red arrows: TL->C, TL->TL, TL->TR, TL->BL, TL->BR; TR->C, TR->TL, TR->TR, TR->BL, TR->BR; BL->C, BL->TL, BL->TR, BL->BL, BL->BR; BR->C, BR->TL, BR->TR, BR->BL, BR->BR; top left (TL), top right (TR), bottom left (BL), bottom right (BR), center (C) ### Operating system and version Windows 10 <!-- ticket-relay:files --> ### Attachments ![Screenshot\_8.png](https://ticket

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #9623, open)
Base
59cb4c49bd236df04d8e37d4764253f29ca6600a
Size
+66 −0 in 2 files
Review
pending
Shared
2026-10-28
Id
765adaf1be5ced11d58e3520

How it went

No other operator has reported using it yet.

Diff

raw · crates/paint/src/brush_tests.rs crates/paint/src/dynamics.rs

diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs--- a/crates/paint/src/brush_tests.rs+++ b/crates/paint/src/brush_tests.rs@@ -246,6 +246,43 @@ fn pencil_1px_line_is_the_same_whichever_way_and_however_smoothed() {     assert_eq!(sampled, pixels((1.5, 1.5), (20.5, 9.5), 0.0)); } +#[test]+fn pencil_1px_freehand_line_never_breaks_where_it_turns() {+    // #8040: each segment of a freehand stroke samples its own line, so where the stroke turned+    // the last pixel of one segment and the first of the next could sit two pixels apart.+    let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() };+    let mut seed = 0x2545_f491_4f6c_dd1du64;+    let mut rnd = || {+        seed ^= seed << 13;+        seed ^= seed >> 7;+        seed ^= seed << 17;+        (seed >> 11) as f64 / (1u64 << 53) as f64+    };+    for _ in 0..300 {+        let mut pts = vec![StrokePoint::new(20.0 + rnd() * 8.0, 20.0 + rnd() * 8.0, 1.0)];+        for _ in 0..6 {+            let l = *pts.last().unwrap();+            let (dx, dy) = ((rnd() - 0.5) * 6.0, (rnd() - 0.5) * 6.0);+            pts.push(StrokePoint::new((l.x + dx).clamp(1.0, 46.0), (l.y + dy).clamp(1.0, 46.0), 1.0));+        }+        let s = paint(&b, &pts, 48, 48);+        let painted: Vec<(i32, i32)> = (0..48).flat_map(|y| (0..48).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect();+        // Every painted pixel is reachable from the first through 8-connected neighbours.+        let mut reached = vec![painted[0]];+        let mut i = 0;+        while i < reached.len() {+            let (x, y) = reached[i];+            for &q in &painted {+                if (q.0 - x).abs() <= 1 && (q.1 - y).abs() <= 1 && !reached.contains(&q) {+                    reached.push(q);+                }+            }+            i += 1;+        }+        let path: Vec<(f64, f64)> = pts.iter().map(|p| (p.x, p.y)).collect();+        assert_eq!(reached.len(), painted.len(), "the line breaks along {path:?}");+    }+} // ---------- spacing ----------  #[test]diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs--- a/crates/paint/src/dynamics.rs+++ b/crates/paint/src/dynamics.rs@@ -21,6 +21,8 @@ pub const SPEED_SPACING_MS: f64 = 8.0;  /// Longest pixel-grid segment (in pixels) before falling back to distance spacing. const MAX_GRID_STEPS: f64 = 1e7;+/// Widest gap (in pixels) [`PathWalker::bridge`] fills between two pixel-grid segments.+const MAX_BRIDGE: f64 = 4.0;  /// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer). const MIN_SPEED_STEP: f64 = 0.5;@@ -242,6 +244,8 @@ pub struct PathWalker {     /// Pixel-grid stepping for small aliased tips: the dab-centre offset inside a pixel (see     /// [`PathWalker::pixel_grid`]).     grid: Option<f64>,+    /// The grid cell of the last pixel-grid step, so the next segment joins it (#8040).+    cell: Option<(f64, f64)>, }  impl PathWalker {@@ -258,6 +262,7 @@ impl PathWalker {             speed: None,             speed_acc: 0.0,             grid: None,+            cell: None,         }     } @@ -296,11 +301,32 @@ impl PathWalker {             let mut q = lerp_pt(a, p, t.clamp(0.0, 1.0));             let cn = grid_cell(an + dn * t, offset);             (q.x, q.y) = if steep { (cn, c) } else { (c, cn) };+            self.bridge(&q, out);             self.emit(q, out);         }         true     } +    /// Fills the pixels between the last grid step and `q` when they don't touch. Each segment+    /// samples its own line, so where a freehand stroke turns, the last pixel of one segment and+    /// the first of the next can sit two pixels apart, and the 1 px Pencil line breaks (#8040).+    fn bridge(&mut self, q: &StrokePoint, out: &mut impl FnMut(StepInput)) {+        let Some((mut x, mut y)) = self.cell else { return };+        // Only a turn's small gap: a step cut off by the walker's budget is not filled in.+        if (q.x - x).abs().max((q.y - y).abs()) > MAX_BRIDGE {+            return;+        }+        while (q.x - x).abs() > 1.5 || (q.y - y).abs() > 1.5 {+            if (q.x - x).abs() > 0.5 {+                x += (q.x - x).signum();+            }+            if (q.y - y).abs() > 0.5 {+                y += (q.y - y).signum();+            }+            self.emit(StrokePoint { x, y, ..*q }, out);+        }+    }+     /// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement     /// spreads them out. Segments without timestamps are spaced by distance.     pub fn speed_spacing(mut self, interval_ms: f64) -> Self {@@ -309,6 +335,9 @@ impl PathWalker {     }      fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {+        if let Some(g) = self.grid {+            self.cell = Some((grid_cell(point.x, g), grid_cell(point.y, g)));+        }         let s = StepInput { point, direction: self.dir, initial_direction: self.initial_dir.unwrap_or(self.dir), step: self.step };         self.step += 1;         out(s);