Files
photocraft/crates/compose/src/pattern.rs
72d78f3067 Patterns paint in the document's colour mode (Gray, CMYK) (#1950)
An RGB pattern (every built-in one) showed in colour as a pattern fill or
Pattern Overlay in a Grayscale or CMYK document, unlike Edit > Fill, the
export, and Photoshop, which fills in grey but keeps the pattern's colours
(a Grayscale round trip brings them back).

pattern::pattern_rgba projects a pattern's pixels through the document's
mode when painted; the CPU prepared tiles and the GPU pattern textures use
it. Patterns themselves are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:12:45 -04:00

275 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Pattern paint: Pattern Overlay, pattern strokes and pattern fill layers.
//!
//! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas
//! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with
//! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile
//! exactly and scaled ones stay seamless.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
const MAX_PREPARED_PATTERNS: usize = 64;
type PreparedSlot = Arc<OnceLock<Option<Arc<Tile>>>>;
struct PreparedState {
slots: HashMap<usize, PreparedSlot>,
reserved_bytes: usize,
}
/// Demand-prepared pixels owned by one rendering call, never by the document.
pub(crate) struct PreparedPatterns<'a> {
patterns: &'a [Pattern],
budget: usize,
/// The colour mode the patterns are painted into (see [`pattern_rgba`]).
mode: ColorMode,
state: Mutex<PreparedState>,
}
impl<'a> PreparedPatterns<'a> {
pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self {
Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) }
}
/// Patterns painted into a document of colour mode `mode`.
pub(crate) fn in_mode(mut self, mode: ColorMode) -> Self {
self.mode = mode;
self
}
pub(crate) fn source(&self) -> &'a [Pattern] {
self.patterns
}
pub(crate) fn get(&self, id: &str, name: &str) -> Option<Arc<Tile>> {
let p = photocraft_doc::pattern::find(self.patterns, id, name)?;
if p.is_empty() {
return None;
}
let bytes = usize::try_from(p.width)
.ok()
.and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h)))
.and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>()));
// The borrowed immutable slice pins these addresses for the complete call.
let key = std::ptr::from_ref(p) as usize;
let slot = {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(slot) = state.slots.get(&key) {
Some(Arc::clone(slot))
} else if let Some(bytes) = bytes
&& bytes <= self.budget.saturating_sub(state.reserved_bytes)
&& state.slots.len() < MAX_PREPARED_PATTERNS
{
let slot: PreparedSlot = Arc::new(OnceLock::new());
// Reserve before initialization: concurrent pending buffers consume the budget too.
state.reserved_bytes += bytes;
state.slots.insert(key, Arc::clone(&slot));
Some(slot)
} else {
None
}
};
match slot {
Some(slot) => {
// Conversion must stay serial: Rayon work stealing can re-enter this slot
// while its initializer is running inside an effect-map OnceLock.
// Run on the requesting worker to preserve its active CMYK profile.
slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone()
}
None => Tile::new_in(p, self.mode).map(Arc::new),
}
}
}
/// A pattern's pixels (straight-alpha RGBA, row-major) as they look painted into a document of
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
/// pattern in colour in its picker, filled in grey).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let mut px = vec![[0.0f32; 4]; (p.width as usize).saturating_mul(p.height as usize)];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in &mut px {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
}
px
}
/// A pattern converted once to straight-alpha RGBA for sampling.
pub struct Tile {
w: usize,
h: usize,
/// Premultiplied RGBA.
px: Vec<[f32; 4]>,
}
impl Tile {
pub fn new(p: &Pattern) -> Option<Tile> {
Tile::new_in(p, ColorMode::Rgb)
}
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
if p.is_empty() {
return None;
}
let (w, h) = (p.width as usize, p.height as usize);
let mut px = pattern_rgba(p, mode);
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
}
}
Some(Tile { w, h, px })
}
#[inline]
fn at(&self, x: i64, y: i64) -> [f32; 4] {
let xi = x.rem_euclid(self.w as i64) as usize;
let yi = y.rem_euclid(self.h as i64) as usize;
self.px[yi * self.w + xi]
}
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
/// Returns straight alpha.
pub fn sample(&self, u: f64, v: f64) -> [f32; 4] {
let (u, v) = (u - 0.5, v - 0.5);
let (x0, y0) = (u.floor(), v.floor());
let (fx, fy) = ((u - x0) as f32, (v - y0) as f32);
let (x0, y0) = (x0 as i64, y0 as i64);
let mut acc = [0.0f32; 4];
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
if wy == 0.0 {
continue;
}
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
if wx == 0.0 {
continue;
}
let p = self.at(x0 + dx, y0 + dy);
let k = wx * wy;
for c in 0..4 {
acc[c] += p[c] * k;
}
}
}
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
}
}
/// Where a pattern sits: document point → tile coordinates.
#[derive(Clone, Copy, Debug)]
pub struct Placement {
origin: (f64, f64),
/// Inverse rotation (cos, sin) and inverse scale.
cs: (f64, f64),
inv_scale: f64,
}
impl Placement {
/// `frame`: the layer's frame (used when `link`); `phase` in pixels; `scale` as a fraction;
/// `angle` in degrees counter-clockwise.
pub fn new(frame: Rect, link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) };
Self::anchored(anchor, link, phase, scale, angle)
}
/// Like [`Placement::new`], with the linked origin given as a point. Layer effects anchor
/// linked patterns at the layer's effects reference point (PSD `fxrp`): Photoshop's
/// pattern overlays and pattern strokes match pixel-for-pixel tiled from there.
pub fn anchored(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
/// (origin, inverse rotation (cos, sin), inverse scale), for GPU shaders.
pub fn parts(&self) -> ((f64, f64), (f64, f64), f64) {
(self.origin, self.cs, self.inv_scale)
}
/// Tile coordinates of the document point `(x, y)`.
#[inline]
pub fn map(&self, x: f64, y: f64) -> (f64, f64) {
let (dx, dy) = (x - self.origin.0, y - self.origin.1);
// Undo a counter-clockwise rotation (y points down on screen).
let (c, s) = self.cs;
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
(rx * self.inv_scale, ry * self.inv_scale)
}
}
/// Renders `tile` over `rect` (row-major straight RGBA).
pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> {
let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5);
out.push(tile.sample(u, v));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::{PixelFormat, Surface};
fn checker() -> Pattern {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 0.0, 1.0, 1.0]);
s.fill_rect(Rect::new(2, 2, 4, 4), &[0.0, 0.0, 1.0, 1.0]);
Pattern::new("checker", s, 4, 4)
}
#[test]
fn identity_placement_tiles_exactly() {
let patterns = [checker()];
let prepared = PreparedPatterns::new(&patterns, PREPARED_PATTERN_BYTES);
let t = prepared.get(&patterns[0].id, "").unwrap();
let again = prepared.get(&patterns[0].id, "").unwrap();
assert!(Arc::ptr_eq(&t, &again));
let uncached = PreparedPatterns::new(&patterns, 0);
let fallback = uncached.get(&patterns[0].id, "").unwrap();
let p = Placement::new(Rect::new(0, 0, 10, 10), false, (0.0, 0.0), 1.0, 0.0);
let px = render(&t, &p, Rect::new(-4, 0, 8, 1));
assert_eq!(px, render(&fallback, &p, Rect::new(-4, 0, 8, 1)));
assert_eq!(px[0], [0.0, 0.0, 1.0, 1.0]); // x = -4 wraps to 0
assert_eq!(px[6], [1.0, 0.0, 0.0, 1.0]); // x = 2
assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4
}
#[test]
fn link_phase_scale_and_angle_move_the_origin() {
let t = Tile::new(&checker()).unwrap();
let linked = Placement::new(Rect::new(2, 0, 10, 10), true, (0.0, 0.0), 1.0, 0.0);
assert_eq!(render(&t, &linked, Rect::new(2, 0, 3, 1))[0], [0.0, 0.0, 1.0, 1.0]);
let phased = Placement::new(Rect::EMPTY, false, (2.0, 0.0), 1.0, 0.0);
assert_eq!(render(&t, &phased, Rect::new(2, 0, 3, 1))[0], [0.0, 0.0, 1.0, 1.0]);
let scaled = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 2.0, 0.0);
let row = render(&t, &scaled, Rect::new(0, 1, 8, 2));
assert_eq!(row[2], [0.0, 0.0, 1.0, 1.0]);
assert_eq!(row[6], [1.0, 0.0, 0.0, 1.0]);
// 90°: x and y swap roles.
let rot = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 90.0);
let (u, v) = rot.map(1.0, 3.0);
assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}");
}
}