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 
- 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
@@ -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]@@ -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);