2026-10-01 08:25:06 -04:00
//! Patterns: Edit › Define Pattern, the pattern library (`pattern.*`), Layer › New Fill Layer ›
//! Pattern, Edit › Fill with a pattern and brush textures from library patterns.
//!
//! Two places hold patterns, as in Photoshop:
//! - the **library** ([`PatternLibrary`], on the [`Session`]): built-in procedural patterns plus
//! defined and imported ones. Library edits are app state, not document history.
//! - the **document** ([`photocraft_doc::Document::patterns`]): the patterns its layers use
//! (saved in PSD `Patt` blocks and `.pcraft`). Commands that apply a pattern copy it into the
//! document in the same history step, so a document always renders on its own.
//!
//! Patterns are referenced by `"pattern": id | name` (document first, then library).
use photocraft_color ::{ ColorMode , PixelFormat , SampleType };
use photocraft_doc ::{ Color , Document , Fill , Layer , LayerContent , Pattern , Rect };
use photocraft_raster ::Surface ;
use serde_json ::{ Value , json };
2026-10-05 19:48:03 -04:00
use crate ::commands ::CommandSpec ;
2026-10-01 08:25:06 -04:00
use crate ::{ EngineError , Result , Session };
/// The app-level pattern library (Window › Patterns).
#[derive(Clone, Debug)]
pub struct PatternLibrary {
pub items : Vec < Pattern > ,
2026-10-25 08:18:23 -06:00
/// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose
/// id is already in the library), which its id, name and size don't show.
pub pixels_rev : u64 ,
2026-10-01 08:25:06 -04:00
}
impl Default for PatternLibrary {
fn default () -> Self {
2026-10-25 08:18:23 -06:00
PatternLibrary { items : builtin (), pixels_rev : 0 }
2026-10-01 08:25:06 -04:00
}
}
2026-10-25 08:18:23 -06:00
impl PatternLibrary {
/// Add `pat`, or replace the pattern with its id (marking the pixels changed).
pub fn upsert ( & mut self , pat : Pattern ) {
match self . items . iter_mut (). find ( | q | q . id == pat . id ) {
Some ( q ) => {
* q = pat ;
self . pixels_rev += 1 ;
}
None => self . items . push ( pat ),
}
}
}
/// Whether `p` is a built-in pattern as shipped (same id, name, size and pixels): those are never
/// saved. A built-in replaced by an import, or renamed, is the user's and is.
pub ( crate ) fn is_pristine_builtin ( p : & Pattern , builtins : & [ Pattern ]) -> bool {
builtins . iter (). any ( | b | b . id == p . id && b . name == p . name && ( b . width , b . height ) == ( p . width , p . height ) && b . surface == p . surface )
}
2026-10-01 08:25:06 -04:00
fn hash01 ( x : u32 , y : u32 , seed : u32 ) -> f32 {
let mut h = x . wrapping_mul ( 0x8da6_b343 ) ^ y . wrapping_mul ( 0xd816_3841 ) ^ seed . wrapping_mul ( 0xcb1a_b31f );
h ^= h >> 13 ;
h = h . wrapping_mul ( 0x5bd1_e995 );
h ^= h >> 15 ;
( h & 0xffff ) as f32 / 65535.0
}
fn procedural ( name : & str , w : u32 , h : u32 , f : impl Fn ( u32 , u32 ) -> [ f32 ; 3 ]) -> Pattern {
let fmt = PixelFormat ::new ( ColorMode ::Rgb , SampleType ::U8 , false );
let mut s = Surface ::new ( fmt );
let mut px = Vec ::with_capacity (( w * h * 3 ) as usize );
for y in 0 .. h {
for x in 0 .. w {
px . extend_from_slice ( & f ( x , y ));
}
}
// Quantise through the 8-bit surface so ids are stable.
s . write_region ( Rect ::new ( 0 , 0 , w as i32 , h as i32 ), & px );
Pattern ::new ( name , s , w , h )
}
/// Built-in patterns: generated procedurally here (our own, CC0), so the library is never empty.
pub fn builtin () -> Vec < Pattern > {
let g = | v : f32 | [ v , v , v ];
vec! [
procedural ( "Checkerboard" , 16 , 16 , | x , y | g ( if ( x / 8 + y / 8 ) % 2 == 0 { 0.8 } else { 1.0 })),
procedural ( "Diagonal Lines" , 8 , 8 , | x , y | g ( if ( x + y ) % 8 < 2 { 0.2 } else { 1.0 })),
procedural ( "Dots" , 12 , 12 , | x , y | {
let ( dx , dy ) = ( x as f32 + 0.5 - 6.0 , y as f32 + 0.5 - 6.0 );
g ((( dx * dx + dy * dy ). sqrt () - 3.0 ). clamp ( 0.0 , 1.0 ))
}),
procedural ( "Grid" , 16 , 16 , | x , y | g ( if x == 0 || y == 0 { 0.55 } else { 1.0 })),
procedural ( "Bricks" , 32 , 16 , | x , y | {
let row = y / 8 ;
let xo = ( x + if row % 2 == 1 { 8 } else { 0 }) % 16 ;
let mortar = y % 8 == 7 || xo == 15 ;
let n = hash01 ( x , y , 7 ) * 0.08 ;
if mortar { [ 0.78 + n , 0.76 + n , 0.72 + n ] } else { [ 0.62 + n , 0.27 + n * 0.5 , 0.2 ] }
}),
procedural ( "Weave" , 16 , 16 , | x , y | {
let over = (( x / 4 ) + ( y / 4 )) % 2 == 0 ;
let t = if over { ( y % 4 ) as f32 / 3.0 } else { ( x % 4 ) as f32 / 3.0 };
let v = 0.55 + 0.35 * ( 1.0 - ( 2.0 * t - 1.0 ). abs ());
[ v , v * 0.92 , v * 0.8 ]
}),
procedural ( "Paper Noise" , 64 , 64 , | x , y | {
// Two octaves of smoothed value noise (wraps at 64 px).
let oct = | cell : u32 , seed : u32 | {
let ( cx , cy ) = ( x / cell , y / cell );
let ( fx , fy ) = (( x % cell ) as f32 / cell as f32 , ( y % cell ) as f32 / cell as f32 );
let n = 64 / cell ;
let v = | i : u32 , j : u32 | hash01 ( i % n , j % n , seed );
let s = | t : f32 | t * t * ( 3.0 - 2.0 * t );
let ( sx , sy ) = ( s ( fx ), s ( fy ));
let a = v ( cx , cy ) + ( v ( cx + 1 , cy ) - v ( cx , cy )) * sx ;
let b = v ( cx , cy + 1 ) + ( v ( cx + 1 , cy + 1 ) - v ( cx , cy + 1 )) * sx ;
a + ( b - a ) * sy
};
g ( 0.78 + 0.14 * oct ( 8 , 1 ) + 0.08 * oct ( 2 , 2 ))
}),
]
}
fn bad ( cmd : & str , msg : impl Into < String > ) -> EngineError {
EngineError ::BadParams { cmd : cmd . into (), msg : msg . into () }
}
fn always ( _ : & Session ) -> std ::result ::Result < (), String > {
Ok (())
}
fn has_doc ( s : & Session ) -> std ::result ::Result < (), String > {
s . active (). map ( | _ | ()). ok_or_else ( || "no document open" . into ())
}
fn has_pattern ( s : & Session ) -> std ::result ::Result < (), String > {
if s . patterns . items . is_empty () && s . active (). is_none_or ( | d | d . doc . patterns . is_empty ()) { Err ( "no patterns" . into ()) } else { Ok (()) }
}
/// Finds a pattern by id or name: the active document's first, then the library. An empty key
/// picks the first library pattern (Photoshop's default).
pub fn resolve ( s : & Session , key : & str ) -> Option < Pattern > {
if key . is_empty () {
2026-10-01 22:49:07 -04:00
// The Patterns panel's selection, else the first library pattern.
return crate ::presets ::patterns ::current ( s ). or ( s . patterns . items . first ()). cloned ();
2026-10-01 08:25:06 -04:00
}
if let Some ( d ) = s . active ()
&& let Some ( p ) = photocraft_doc ::pattern ::find ( & d . doc . patterns , key , key )
{
return Some ( p . clone ());
}
photocraft_doc ::pattern ::find ( & s . patterns . items , key , key ). cloned ()
}
2026-10-03 13:07:52 -04:00
pub ( crate ) fn resolve_param ( s : & Session , cmd : & str , p : & Value ) -> Result < Pattern > {
2026-10-01 08:25:06 -04:00
let key = p . get ( "pattern" ). and_then ( Value ::as_str ). unwrap_or ( "" );
resolve ( s , key ). ok_or_else ( || bad ( cmd , format! ( "no pattern \" {key} \" (see pattern.list)" )))
}
/// Copies `pat` into the document's patterns unless it is already there (by id).
pub fn ensure_in_doc ( doc : & mut Document , pat : & Pattern ) {
if ! doc . patterns . iter (). any ( | p | p . id == pat . id ) {
doc . patterns . push ( pat . clone ());
}
}
/// Resolves the pattern of a Pattern Overlay / pattern stroke built from params (its `name`
/// holds the requested key) into a real pattern: returns the effect with name and id filled in,
/// plus the pattern to copy into the document.
pub fn resolve_effect ( s : & Session , fx : photocraft_doc ::Effect ) -> Result < ( photocraft_doc ::Effect , Option < Pattern > ) > {
use photocraft_doc ::Effect ;
match fx {
Effect ::PatternOverlay { common , name , id , scale , angle , link , phase } => {
let key = if id . is_empty () { name } else { id };
let pat = resolve ( s , & key ). ok_or_else ( || bad ( "layer.layerStyle.patternOverlay" , format! ( "no pattern \" {key} \" (see pattern.list)" ))) ? ;
Ok (( Effect ::PatternOverlay { common , name : pat . name . clone (), id : pat . id . clone (), scale , angle , link , phase }, Some ( pat )))
}
other => Ok (( other , None )),
}
}
fn info ( p : & Pattern , in_doc : bool , in_library : bool ) -> Value {
let f = p . surface . format ();
json! ({
"id" : p . id , "name" : p . display_name (), "storedName" : p . name , "width" : p . width , "height" : p . height ,
"mode" : format ! ( "{:?}" , f . mode ), "depth" : format ! ( "{:?}" , f . sample ), "alpha" : f . alpha ,
"inDocument" : in_doc , "inLibrary" : in_library ,
})
}
fn list ( s : & mut Session , _ : & Value ) -> Result < Value > {
let doc_pats : Vec < Pattern > = s . active (). map ( | d | d . doc . patterns . clone ()). unwrap_or_default ();
let mut out : Vec < Value > = s . patterns . items . iter (). map ( | p | info ( p , doc_pats . iter (). any ( | d | d . id == p . id ), true )). collect ();
out . extend ( doc_pats . iter (). filter ( | d | ! s . patterns . items . iter (). any ( | p | p . id == d . id )). map ( | p | info ( p , true , false )));
Ok ( Value ::Array ( out ))
}
/// The visible composite over `r` as a surface in the document's pixel format (with alpha).
fn composite_surface ( doc : & Document , r : Rect ) -> Surface {
let buf = photocraft_compose ::render ( doc , r );
let df = doc . pixel_format ();
let fmt = PixelFormat ::new ( df . mode , df . sample , true );
let n = fmt . channels ();
let mut px = vec! [ 0.0 f32 ; buf . px . len () * n ];
for ( o , p ) in px . chunks_exact_mut ( n ). zip ( & buf . px ) {
photocraft_raster ::from_rgba_into ( & fmt , * p , o );
}
let mut s = Surface ::new ( fmt );
s . write_region ( Rect ::new ( 0 , 0 , r . width () as i32 , r . height () as i32 ), & px );
s
}
2026-10-09 09:22:50 -07:00
/// The name Define Pattern gives a pattern without one: the active document's name without its
/// extension ("Pattern" without a document), numbered past the library's existing names. The
/// Define Pattern dialog starts from it.
pub fn default_name ( s : & Session ) -> String {
let doc_name = s . active (). map ( | st | st . doc . name . clone ()). unwrap_or_default ();
let base =
std ::path ::Path ::new ( & doc_name ). file_stem (). map ( | x | x . to_string_lossy (). to_string ()). filter ( | b | ! b . is_empty ()). unwrap_or_else ( || "Pattern" . into ());
let taken = | n : & str | s . patterns . items . iter (). any ( | q | q . name == n );
if ! taken ( & base ) {
return base ;
}
( 2 .. ). map ( | i | format! ( " {base} {i} " )). find ( | n | ! taken ( n )). unwrap_or ( base )
}
2026-10-01 08:25:06 -04:00
fn define ( s : & mut Session , p : & Value ) -> Result < Value > {
let cmd = "edit.definePattern" ;
let st = s . active (). ok_or ( EngineError ::NoDocument ) ? ;
let doc = & st . doc ;
let r = match p . get ( "rect" ). and_then ( Value ::as_array ) {
Some ( a ) if a . len () == 4 => {
let v : Vec < i32 > = a . iter (). map ( | x | x . as_f64 (). unwrap_or ( 0.0 ). round () as i32 ). collect ();
Rect ::new ( v [ 0 ], v [ 1 ], v [ 2 ], v [ 3 ])
}
_ => doc . selection . as_ref (). map_or ( doc . bounds (), | m | m . content_bounds ()),
}
. intersect ( & doc . bounds ());
if r . is_empty () {
return Err ( bad ( cmd , "the area is empty" ));
}
if r . width () > 4000 || r . height () > 4000 {
return Err ( bad ( cmd , "patterns are limited to 4000 × 4000 px" ));
}
let mut surf = composite_surface ( doc , r );
// Opaque patterns drop their alpha channel (as Photoshop does).
let fmt = surf . format ();
let px = surf . read_region ( Rect ::new ( 0 , 0 , r . width () as i32 , r . height () as i32 ));
if px . chunks_exact ( fmt . channels ()). all ( | q | q [ fmt . channels () - 1 ] >= 1.0 ) {
surf = surf . convert ( PixelFormat ::new ( fmt . mode , fmt . sample , false ));
}
let name = match p . get ( "name" ). and_then ( Value ::as_str ) {
Some ( n ) if ! n . trim (). is_empty () => n . to_string (),
2026-10-09 09:22:50 -07:00
_ => default_name ( s ),
2026-10-01 08:25:06 -04:00
};
let pat = Pattern ::new ( name , surf , r . width (), r . height ());
let id = pat . id . clone ();
let out = info ( & pat , false , true );
if ! s . patterns . items . iter (). any ( | q | q . id == id ) {
s . patterns . items . push ( pat );
}
Ok ( json! ({ "pattern" : id , "info" : out }))
}
fn library_index ( s : & Session , cmd : & str , p : & Value ) -> Result < usize > {
let key = p . get ( "pattern" ). and_then ( Value ::as_str ). ok_or_else ( || bad ( cmd , "missing `pattern` (id or name)" )) ? ;
let found =
photocraft_doc ::pattern ::find ( & s . patterns . items , key , key ). map ( | f | f . id . clone ()). ok_or_else ( || bad ( cmd , format! ( "no library pattern \" {key} \" " ))) ? ;
Ok ( s . patterns . items . iter (). position ( | q | q . id == found ). unwrap_or ( 0 ))
}
fn rename ( s : & mut Session , p : & Value ) -> Result < Value > {
let cmd = "pattern.rename" ;
let name = p . get ( "name" ). and_then ( Value ::as_str ). filter ( | n | ! n . trim (). is_empty ()). ok_or_else ( || bad ( cmd , "missing `name`" )) ? . to_string ();
let i = library_index ( s , cmd , p ) ? ;
let id = s . patterns . items [ i ]. id . clone ();
s . patterns . items [ i ]. name = name . clone ();
// A copy stored in the document is renamed too (a document edit, so undoable).
if s . active (). is_some_and ( | d | d . doc . patterns . iter (). any ( | q | q . id == id )) {
s . edit ( "Rename Pattern" , | doc , _ | {
for q in doc . patterns . iter_mut (). filter ( | q | q . id == id ) {
q . name = name . clone ();
}
Ok (())
}) ? ;
}
Ok ( json! ({ "pattern" : id , "name" : name }))
}
fn delete ( s : & mut Session , p : & Value ) -> Result < Value > {
let i = library_index ( s , "pattern.delete" , p ) ? ;
let pat = s . patterns . items . remove ( i );
Ok ( json! ({ "deleted" : pat . id }))
}
fn import ( s : & mut Session , p : & Value ) -> Result < Value > {
let cmd = "pattern.import" ;
2026-10-12 22:10:58 -07:00
// A file on disk, or its bytes (a dropped file, the web build).
let bytes = match ( p . get ( "path" ). and_then ( Value ::as_str ), p . get ( "data" ). and_then ( Value ::as_str )) {
( Some ( path ), _ ) => std ::fs ::read ( path ). map_err ( | e | EngineError ::Other ( format! ( " {path} : {e} " ))) ? ,
( None , Some ( b64 )) => photocraft_paint ::tile ::b64_decode ( b64 ). ok_or_else ( || bad ( cmd , "`data` is not base64" )) ? ,
( None , None ) => return Err ( bad ( cmd , "missing `path` (.pat file) or `data`" )),
};
2026-10-01 08:25:06 -04:00
let pats = photocraft_io ::pattern_map ::read_pat ( & bytes ). map_err ( EngineError ::Other ) ? ;
let mut ids = Vec ::new ();
for pat in pats {
ids . push ( pat . id . clone ());
2026-10-25 08:18:23 -06:00
s . patterns . upsert ( pat );
2026-10-01 08:25:06 -04:00
}
2026-10-12 22:10:58 -07:00
Ok ( json! ({ "imported" : ids , "count" : ids . len ()}))
2026-10-01 08:25:06 -04:00
}
fn export ( s : & mut Session , p : & Value ) -> Result < Value > {
let cmd = "pattern.export" ;
let path = p . get ( "path" ). and_then ( Value ::as_str ). ok_or_else ( || bad ( cmd , "missing `path`" )) ? ;
let pats : Vec < Pattern > = match p . get ( "patterns" ). and_then ( Value ::as_array ) {
Some ( keys ) => {
keys . iter (). filter_map ( Value ::as_str ). map ( | k | resolve ( s , k ). ok_or_else ( || bad ( cmd , format! ( "no pattern \" {k} \" " )))). collect ::< Result < _ >> () ?
2026-10-04 10:20:46 -04:00
}
2026-10-01 08:25:06 -04:00
None => s . patterns . items . clone (),
};
let bytes = photocraft_io ::pattern_map ::write_pat ( & pats ). map_err ( EngineError ::Other ) ? ;
2026-10-05 11:16:13 -04:00
crate ::file_cmds ::write_file ( path , & bytes ) ? ;
2026-10-01 08:25:06 -04:00
Ok ( json! ({ "path" : path , "count" : pats . len (), "bytes" : bytes . len ()}))
}
2026-10-14 22:34:51 +02:00
/// A pattern's scale, angle, link and phase from `p`. The angle is normalised to (-180, 180];
/// a non-finite angle or a phase beyond ±`MAX_PHASE` is an error (it would render NaN).
pub ( crate ) fn placement ( cmd : & str , p : & Value ) -> Result < ( f32 , f32 , bool , ( f32 , f32 )) > {
let scale = p . get ( "scale" ). and_then ( Value ::as_f64 ). unwrap_or ( 100.0 ) as f32 ;
let max = crate ::pattern_fill_cmds ::MAX_PHASE ;
let a = p . get ( "phase" ). and_then ( Value ::as_array );
let c = | i : usize | {
let v = a . and_then ( | a | a . get ( i )). and_then ( Value ::as_f64 ). unwrap_or ( 0.0 );
( v . is_finite () && v . abs () <= max ). then_some ( v as f32 ). ok_or_else ( || bad ( cmd , format! ( "`phase` needs finite coordinates within ± {max} " )))
};
let phase = ( c ( 0 ) ? , c ( 1 ) ? );
let angle = crate ::gradient_fill_cmds ::angle_param ( cmd , p , 0.0 ) ? ;
Ok ((( scale / 100.0 ). clamp ( 0.01 , 10.0 ), angle , p . get ( "link" ). and_then ( Value ::as_bool ). unwrap_or ( true ), phase ))
2026-10-01 08:25:06 -04:00
}
2026-10-11 15:16:18 +02:00
/// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks,
/// above `active`. The Pattern Fill dialog previews it on a copy of the document.
2026-10-12 08:19:25 +03:00
pub ( crate ) fn insert_fill_layer (
doc : & mut Document ,
active : & mut Option < photocraft_doc ::LayerId > ,
pat : & Pattern ,
p : & Value ,
default_mask : bool ,
) -> Result < photocraft_doc ::LayerId > {
2026-10-14 22:34:51 +02:00
let ( scale , angle , link , phase ) = placement ( "layer.newFillLayer.pattern" , p ) ? ;
2026-10-11 15:16:18 +02:00
ensure_in_doc ( doc , pat );
let fill = Fill ::Pattern { name : pat . name . clone (), scale , id : pat . id . clone (), angle , link , phase };
let mut l = Layer ::new ( doc . next_layer_name ( "Pattern Fill" ), LayerContent ::Fill ( fill ));
2026-10-12 08:19:25 +03:00
crate ::commands ::mask_new_layer ( doc , & mut l , p , "layer.newFillLayer.pattern" , default_mask ) ? ;
2026-10-11 15:16:18 +02:00
let id = doc . insert_above ( * active , l );
* active = Some ( id );
Ok ( id )
}
2026-10-18 17:55:32 +03:00
/// `layer.newFillLayer.pattern`: a Pattern Fill layer above the active one, masked as the Layers panel's Use Default Masks on Fill Layers option says.
2026-10-01 08:25:06 -04:00
fn new_fill_layer ( s : & mut Session , p : & Value ) -> Result < Value > {
let pat = resolve_param ( s , "layer.newFillLayer.pattern" , p ) ? ;
2026-10-12 08:19:25 +03:00
let default_mask = crate ::commands ::layer_panel_option ( s , "useDefaultMasksOnFillLayers" , true );
let id = s . edit ( "New Pattern Fill Layer" , | doc , active | insert_fill_layer ( doc , active , & pat , p , default_mask )) ? ;
2026-10-01 08:25:06 -04:00
Ok ( json! ({ "layer" : id . 0 , "pattern" : pat . id }))
}
2026-10-18 17:55:32 +03:00
/// `brush.texturePattern`: use a pattern as the brush texture, converted to grey as the luminance of its composite over white.
2026-10-01 08:25:06 -04:00
fn brush_texture ( s : & mut Session , p : & Value ) -> Result < Value > {
let pat = resolve_param ( s , "brush.texturePattern" , p ) ? ;
2026-10-12 16:06:16 +02:00
let n = pat . is_paintable (). then ( || usize ::try_from ( pat . pixel_count ()). ok ()). flatten ();
let Some ( n ) = n else {
return Err ( bad ( "brush.texturePattern" , "the pattern is empty or too large to paint" ));
};
2026-10-01 08:25:06 -04:00
// Brush textures are grey: luminance of the composite over white.
2026-10-12 16:06:16 +02:00
let mut px = vec! [[ 0.0 f32 ; 4 ]; n ];
2026-10-01 08:25:06 -04:00
pat . surface . read_rgba_into ( pat . rect (), & mut px );
let lum : Vec < f32 > = px
. iter ()
. map ( | q | {
let l = 0.299 * q [ 0 ] + 0.587 * q [ 1 ] + 0.114 * q [ 2 ];
l * q [ 3 ] + ( 1.0 - q [ 3 ])
})
. collect ();
let tile = photocraft_paint ::GrayTile ::from_f32 ( pat . width , pat . height , & lum );
let t = & mut s . tools . brush . texture ;
t . pattern = photocraft_paint ::Pattern ::Tile ( tile );
t . enabled = p . get ( "enabled" ). and_then ( Value ::as_bool ). unwrap_or ( true );
if let Some ( sc ) = p . get ( "scale" ). and_then ( Value ::as_f64 ) {
t . scale = ( sc as f32 / 100.0 ). clamp ( 0.01 , 10.0 );
}
Ok ( json! ({ "pattern" : pat . id }))
}
/// A swatch colour for UIs (average of the tile), straight RGB.
pub fn average_color ( p : & Pattern ) -> Color {
2026-10-12 16:06:16 +02:00
// A pattern too large to paint is averaged over its paintable top-left corner.
let side = photocraft_doc ::pattern ::MAX_PAINT_PIXELS . isqrt () as u32 ;
let ( w , h ) = ( p . width . min ( side ), p . height . min ( side ));
let mut px = vec! [[ 0.0 f32 ; 4 ]; w as usize * h as usize ];
p . surface . read_rgba_into ( Rect ::new ( 0 , 0 , w as i32 , h as i32 ), & mut px );
2026-10-01 08:25:06 -04:00
let n = px . len (). max ( 1 ) as f32 ;
let s = px . iter (). fold ([ 0.0 f32 ; 3 ], | a , q | [ a [ 0 ] + q [ 0 ], a [ 1 ] + q [ 1 ], a [ 2 ] + q [ 2 ]]);
Color ::rgb ( s [ 0 ] / n , s [ 1 ] / n , s [ 2 ] / n )
}
pub fn specs () -> Vec < CommandSpec > {
vec! [
CommandSpec {
id : "edit.definePattern" ,
label : "Define Pattern…" ,
menu : & [ "Edit" ],
shortcut : None ,
2026-10-09 09:22:50 -07:00
params : r ##"{" name ":text," rect ":[x0,y0,x1,y1]?} → {" pattern ":id} (name: default the document's name numbered past the library's names; rect: default the selection bounds or else the canvas; added to the library from the visible composite)" ##,
2026-10-01 08:25:06 -04:00
enabled : has_doc ,
journal : true ,
run : define ,
},
CommandSpec {
id : "pattern.list" ,
label : "Patterns" ,
menu : & [],
shortcut : None ,
params : "{} → [{id,name,width,height,mode,depth,inDocument,inLibrary}]" ,
enabled : always ,
journal : false ,
run : list ,
},
CommandSpec {
id : "pattern.rename" ,
label : "Rename Pattern" ,
menu : & [],
shortcut : None ,
params : r ##"{" pattern ":id|name," name ":str}" ##,
enabled : has_pattern ,
journal : true ,
run : rename ,
},
CommandSpec {
id : "pattern.delete" ,
label : "Delete Pattern" ,
menu : & [],
shortcut : None ,
params : r ##"{" pattern ":id|name} (library only; documents keep their copy)" ##,
enabled : has_pattern ,
journal : true ,
run : delete ,
},
CommandSpec {
id : "pattern.import" ,
label : "Import Patterns…" ,
menu : & [],
shortcut : None ,
2026-10-12 22:10:58 -07:00
params : r ##"{" path ":" . pat file "?," data ":base64 bytes? (instead of a path)} → {imported:[ids],count}" ##,
2026-10-01 08:25:06 -04:00
enabled : always ,
journal : true ,
run : import ,
},
CommandSpec {
id : "pattern.export" ,
label : "Export Patterns…" ,
menu : & [],
shortcut : None ,
params : r ##"{" path ":" . pat file "," patterns ":[id|name]? (default: the whole library)}" ##,
enabled : has_pattern ,
journal : true ,
run : export ,
2026-10-04 10:20:46 -04:00
},
2026-10-01 08:25:06 -04:00
CommandSpec {
id : "layer.newFillLayer.pattern" ,
label : "Pattern…" ,
menu : & [ "Layer" , "New Fill Layer" ],
shortcut : None ,
2026-10-09 03:49:29 +02:00
params : r ##"{" pattern ":id|name?=first library pattern," scale ":1..1000=100," angle ":deg=0," link ":bool=true," phase ":[x,y]?," path ":" work "|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}" ##,
2026-10-01 08:25:06 -04:00
enabled : has_doc ,
journal : true ,
run : new_fill_layer ,
},
CommandSpec {
id : "brush.texturePattern" ,
label : "Brush Texture Pattern" ,
menu : & [],
shortcut : None ,
params : r ##"{" pattern ":id|name," scale ":1..1000?," enabled ":bool=true} (sets the session brush's Texture to that pattern)" ##,
enabled : has_pattern ,
journal : true ,
run : brush_texture ,
},
]
}
#[cfg(test)]
mod tests {
use super ::* ;
use photocraft_doc ::LayerId ;
fn session ( depth : u64 ) -> Session {
let mut s = Session ::new ();
s . execute ( "file.new" , json! ({ "width" : 40 , "height" : 30 , "depth" : depth })). unwrap ();
s
}
fn pixel ( s : & Session , x : i32 , y : i32 ) -> [ f32 ; 4 ] {
let b = photocraft_compose ::render ( & s . active (). unwrap (). doc , Rect ::new ( x , y , x + 1 , y + 1 ));
b . px [ 0 ]
}
#[test]
fn library_has_builtins_and_lists () {
let mut s = Session ::new ();
let v = s . execute ( "pattern.list" , json! ({})). unwrap ();
assert! ( v . as_array (). unwrap (). len () >= 6 );
assert! ( v [ 0 ][ "id" ]. as_str (). unwrap (). len () == 36 );
// Built-in ids are stable across sessions.
assert_eq! ( builtin ()[ 0 ]. id , Session ::new (). patterns . items [ 0 ]. id );
}
#[test]
fn define_rename_delete_and_pat_round_trip () {
let mut s = session ( 8 );
s . execute ( "edit.fill" , json! ({ "color" : "#ff0000" })). unwrap ();
s . execute ( "select.rect" , json! ({ "x" : 2 , "y" : 3 , "width" : 5 , "height" : 4 })). unwrap ();
let r = s . execute ( "edit.definePattern" , json! ({ "name" : "Red" })). unwrap ();
let id = r [ "pattern" ]. as_str (). unwrap (). to_string ();
let pat = resolve ( & s , & id ). unwrap ();
assert_eq! (( pat . width , pat . height ), ( 5 , 4 ));
assert! ( ! pat . surface . format (). alpha , "opaque pattern drops alpha" );
// Same content → same id (replayable); not duplicated.
let n = s . patterns . items . len ();
s . execute ( "edit.definePattern" , json! ({ "name" : "Red" })). unwrap ();
assert_eq! ( s . patterns . items . len (), n );
s . execute ( "pattern.rename" , json! ({ "pattern" : id , "name" : "Crimson" })). unwrap ();
assert_eq! ( resolve ( & s , "Crimson" ). unwrap (). id , id );
let dir = std ::env ::temp_dir (). join ( format! ( "pc-pat- {} " , std ::process ::id ()));
std ::fs ::create_dir_all ( & dir ). unwrap ();
let path = dir . join ( "lib.pat" );
let path = path . to_str (). unwrap ();
s . execute ( "pattern.export" , json! ({ "path" : path })). unwrap ();
s . execute ( "pattern.delete" , json! ({ "pattern" : "Crimson" })). unwrap ();
assert! ( resolve ( & s , "Crimson" ). is_none ());
assert! ( s . execute ( "pattern.delete" , json! ({ "pattern" : "Crimson" })). is_err ());
let v = s . execute ( "pattern.import" , json! ({ "path" : path })). unwrap ();
assert! ( v [ "imported" ]. as_array (). unwrap (). iter (). any ( | x | x == id . as_str ()));
assert_eq! ( resolve ( & s , & id ). unwrap (). surface . read_region ( Rect ::new ( 0 , 0 , 5 , 4 )), pat . surface . read_region ( Rect ::new ( 0 , 0 , 5 , 4 )));
2026-10-12 22:10:58 -07:00
// The same file as bytes (a dropped file, the web build).
let bytes = std ::fs ::read ( path ). unwrap ();
s . execute ( "pattern.delete" , json! ({ "pattern" : "Crimson" })). unwrap ();
let v = s . execute ( "pattern.import" , json! ({ "data" : photocraft_paint ::tile ::b64_encode ( & bytes )})). unwrap ();
assert_eq! ( v [ "count" ], v [ "imported" ]. as_array (). unwrap (). len ());
assert_eq! ( resolve ( & s , "Crimson" ). unwrap (). id , id );
2026-10-01 08:25:06 -04:00
std ::fs ::remove_dir_all ( dir ). ok ();
assert! ( s . execute ( "pattern.import" , json! ({ "path" : "/nonexistent.pat" })). is_err ());
2026-10-12 22:10:58 -07:00
for bad in [ json! ({}), json! ({ "data" : "not base64!" }), json! ({ "data" : "aGVsbG8=" })] {
assert! ( s . execute ( "pattern.import" , bad . clone ()). is_err (), "{bad}" );
}
2026-10-01 08:25:06 -04:00
}
#[test]
fn pattern_fill_layer_renders_at_every_depth_and_undoes () {
for depth in [ 8 u64 , 16 , 32 ] {
let mut s = session ( depth );
let r = s . execute ( "layer.newFillLayer.pattern" , json! ({ "pattern" : "Checkerboard" })). unwrap ();
let id = LayerId ( r [ "layer" ]. as_u64 (). unwrap ());
let st = s . active (). unwrap ();
assert_eq! ( st . doc . patterns . len (), 1 , "copied into the document" );
assert! ( matches! ( & st . doc . layer ( id ). unwrap (). content , LayerContent ::Fill ( Fill ::Pattern { .. })));
// Checkerboard: 8 px squares of 0.8 and 1.0 grey from the canvas origin.
let ( a , b ) = ( pixel ( & s , 1 , 1 ), pixel ( & s , 9 , 1 ));
assert! (( a [ 0 ] - 0.8 ). abs () < 0.01 && ( b [ 0 ] - 1.0 ). abs () < 0.01 , "{depth}: {a:?} {b:?}" );
s . undo ();
assert! ( s . active (). unwrap (). doc . patterns . is_empty ());
assert! ( s . execute ( "layer.newFillLayer.pattern" , json! ({ "pattern" : "nope" })). is_err ());
}
}
2026-10-14 22:34:51 +02:00
#[test]
fn pattern_placement_rejects_non_finite_angle_and_phase () {
// 1e39 overflowed f32 to infinity: a NaN render, and a fill layer .pcraft refused to save.
let mut s = session ( 8 );
let n = s . active (). unwrap (). doc . layers . len ();
for cmd in [ "layer.newFillLayer.pattern" , "edit.fill" , "paint.bucket" ] {
for bad in [ json! ({ "angle" : 1e39 }), json! ({ "angle" : "x" }), json! ({ "phase" : [ 1e39 , 0 ]}), json! ({ "phase" : [ 0 , - 1e300 ]})] {
let mut p = json! ({ "pattern" : "Checkerboard" , "contents" : "pattern" , "x" : 1 , "y" : 1 });
p . as_object_mut (). unwrap (). extend ( bad . as_object (). unwrap (). clone ());
assert! ( s . execute ( cmd , p ). is_err (), "{cmd} {bad}" );
}
}
assert_eq! ( s . active (). unwrap (). doc . layers . len (), n );
// The angle is normalised to (-180, 180] like the Pattern Fill dialog's.
let r = s . execute ( "layer.newFillLayer.pattern" , json! ({ "pattern" : "Checkerboard" , "angle" : 270 , "phase" : [ 3 , - 4 ]})). unwrap ();
let id = LayerId ( r [ "layer" ]. as_u64 (). unwrap ());
let Some ( LayerContent ::Fill ( Fill ::Pattern { angle , phase , .. })) = s . active (). unwrap (). doc . layer ( id ). map ( | l | & l . content ) else { panic! () };
assert_eq! (( * angle , * phase ), ( - 90.0 , ( 3.0 , - 4.0 )));
}
2026-10-01 08:25:06 -04:00
#[test]
fn fill_with_pattern_respects_selection () {
for depth in [ 8 u64 , 16 , 32 ] {
let mut s = session ( depth );
s . execute ( "select.rect" , json! ({ "x" : 0 , "y" : 0 , "width" : 8 , "height" : 8 })). unwrap ();
s . execute ( "edit.fill" , json! ({ "contents" : "pattern" , "pattern" : "Diagonal Lines" })). unwrap ();
let st = s . active (). unwrap ();
let surf = st . doc . layers [ 0 ]. surface (). unwrap ();
assert! ( surf . pixel ( 0 , 0 )[ 0 ] < 0.3 , "{depth}: line pixel" );
assert! ( surf . pixel ( 4 , 0 )[ 0 ] > 0.9 );
assert! ( surf . pixel ( 20 , 20 )[ 0 ] > 0.99 , "outside the selection untouched" );
}
}
#[test]
fn pattern_overlay_composes_with_an_outside_stroke () {
let mut s = session ( 8 );
s . execute ( "layer.new.layer" , json! ({})). unwrap ();
s . execute ( "select.rect" , json! ({ "x" : 10 , "y" : 10 , "width" : 10 , "height" : 10 })). unwrap ();
s . execute ( "edit.fill" , json! ({ "color" : "#0000ff" })). unwrap ();
s . execute ( "select.deselect" , json! ({})). unwrap ();
s . execute ( "layer.layerStyle.patternOverlay" , json! ({ "pattern" : "Diagonal Lines" })). unwrap ();
2026-10-19 15:14:51 +02:00
s . execute ( "layer.layerStyle.stroke" , json! ({ "size" : 3 , "position" : "outside" , "color" : "#00ff00" , "add" : true })). unwrap ();
2026-10-01 08:25:06 -04:00
let out = pixel ( & s , 8 , 15 );
assert! ( out [ 1 ] > 0.9 && out [ 0 ] < 0.1 , "stroke outside: {out:?}" );
}
#[test]
fn pattern_overlay_renders_and_brush_texture_uses_library () {
let mut s = session ( 16 );
s . execute ( "layer.new.layer" , json! ({})). unwrap ();
s . execute ( "edit.fill" , json! ({ "color" : "#0000ff" })). unwrap ();
s . execute ( "layer.layerStyle.patternOverlay" , json! ({ "pattern" : "Checkerboard" , "opacity" : 100 })). unwrap ();
let st = s . active (). unwrap ();
assert_eq! ( st . doc . patterns . len (), 1 );
let p = pixel ( & s , 1 , 1 );
assert! (( p [ 0 ] - 0.8 ). abs () < 0.01 && ( p [ 2 ] - 0.8 ). abs () < 0.01 , "{p:?}" );
assert! ( s . execute ( "layer.layerStyle.patternOverlay" , json! ({ "pattern" : "missing" })). is_err ());
s . execute ( "layer.layerStyle.stroke" , json! ({ "size" : 4 , "color" : "#ffffff" , "add" : true })). unwrap ();
let st = s . active (). unwrap ();
assert_eq! ( st . doc . layer ( st . active_layer . unwrap ()). unwrap (). effects . items . len (), 2 );
s . execute ( "brush.texturePattern" , json! ({ "pattern" : "Dots" , "scale" : 50 })). unwrap ();
assert! ( s . tools . brush . texture . enabled );
assert! ( matches! ( s . tools . brush . texture . pattern , photocraft_paint ::Pattern ::Tile ( _ )));
}
2026-10-12 16:06:16 +02:00
#[test]
fn a_pattern_too_large_to_paint_is_refused_not_allocated () {
// width × height overflows u32: this panicked (debug) or wrapped (release).
let mut s = session ( 8 );
let huge = Pattern { id : "huge" . into (), name : "Huge" . into (), width : 65_536 , height : 65_536 , surface : Surface ::new ( PixelFormat ::RGBA8 ) };
s . patterns . items . push ( huge );
let e = s . execute ( "brush.texturePattern" , json! ({ "pattern" : "huge" })). unwrap_err ();
assert! ( e . to_string (). contains ( "too large" ), "{e}" );
}
2026-10-01 08:25:06 -04:00
}