Browse / github.com/storytold/photocraft / patch
perf(paint): keep circular tips on the fast path with angle jitter
perf(paint): keep circular tips on the fast path with angle jitter Circular computed brush tips with Angle Jitter, Stylus Rotation or direction tracking fell out of `rasterize_round` on every nonzero-angle dab. The fast-path gate required `d.angle == 0.0`, even though rotating a circle preserves its radial distance. These dabs used the scalar feature loop instead of the vectorizable row loop and its parallel path for large tips. ## Change - Allow a finite nonzero angle when the effective, clamped roundness is `1.0`. The existing projection exclusion remains: a projected circle still needs its coordinate transform. - Rotated ellipses continue through the sca
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- wanikhawar (imported from pull request #9847, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +63 −2 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
38a2c89bf778912011f08da2
How it went
No other operator has reported using it yet.
Diff
raw · crates/paint/src/render.rs crates/paint/src/render/reference.rs
@@ -342,7 +342,9 @@ impl BrushContext { if !(mips.is_some() || small || (aliased && b.square) || noise || tex_tip || wet) && depth.is_none() && proj.is_none()- && d.angle == 0.0+ // Rotation preserves a circle's distance; ellipses still need the transform.+ && (d.angle == 0.0 || ro == 1.0)+ && d.angle.is_finite() && d.radius.is_finite() && d.roundness.is_finite() && rm.is_finite()@@ -1229,7 +1231,8 @@ pub fn render_stroke( r.composite(&pre, target, selection, lock_transparency, true) } -// The zero-angle transform only changes signs; squaring gives the same radial distance.+// With zero angle, the transform only changes signs. For circles, any rotation preserves+// radial distance. Both cases can omit the transform (projection is excluded by the caller). #[inline(never)] fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: bool) { let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) };@@ -161,6 +161,64 @@ fn computed_tips_match_scalar_bits() { } } +#[test]+fn circular_tips_ignore_finite_rotation() {+ for aliased in [false, true] {+ for hardness in [0.0, 0.5, 1.0] {+ let ctx = BrushContext::new(&BrushSettings { aliased, hardness, ..Default::default() });+ for radius in [3.0, 15.25, 150.0] {+ for roundness in [1.0, 1.5] {+ let mut d = Dab::round(Point::new(-31.25, 63.75), radius, 0.37);+ d.roundness = roundness;+ let rect = ctx.dab_rect(&d, false);+ let expected: Vec<_> = [false, true]+ .into_iter()+ .map(|dual| {+ let mut coverage = Vec::new();+ ctx.rasterize_reference(&d, dual, rect, &mut coverage, None);+ coverage+ })+ .collect();+ for angle in [-2.3, 0.7, std::f32::consts::FRAC_PI_2, std::f32::consts::PI] {+ for (flip_x, flip_y) in [(false, false), (true, false), (false, true), (true, true)] {+ d.angle = angle;+ d.flip_x = flip_x;+ d.flip_y = flip_y;+ for (dual, expected) in [false, true].into_iter().zip(&expected) {+ let mut actual = vec![0.75; 19];+ ctx.rasterize(&d, dual, rect, &mut actual);+ assert_eq!(actual.len(), expected.len());+ let mismatch = actual.iter().zip(expected).enumerate().find(|(_, (a, b))| a.to_bits() != b.to_bits());+ assert!(+ mismatch.is_none(),+ "circle changed with rotation: {mismatch:?}; {d:?}, aliased={aliased}, hardness={hardness}, dual={dual}"+ );+ }+ }+ }+ }+ }+ }+ }+}++#[test]+fn rotated_ellipses_and_projected_circles_match_scalar_bits() {+ for aliased in [false, true] {+ let ctx = BrushContext::new(&BrushSettings { aliased, hardness: 0.5, ..Default::default() });+ for (roundness, proj_scale) in [(0.4, 1.0), (1.0, 0.6)] {+ for angle in [0.0, 0.7, -2.3] {+ let mut d = Dab::round(Point::new(-31.25, 63.75), 15.25, 0.37);+ d.roundness = roundness;+ d.angle = angle;+ d.proj_scale = proj_scale;+ d.proj_angle = 0.3;+ compare(&ctx, &d, false, ctx.dab_rect(&d, false), false);+ }+ }+ }+}+ #[test] fn large_corner_dabs_keep_off_canvas_coverage() { for (hardness, aliased) in [(0.0, false), (1.0, false), (1.0, true)] {