artwork.rs

12.9 kB · rust · 389 lines

1use crate::two::{self, tile as tile2d};2use mrlycore::errors::{value_error, MrlyError, Result};3use mrlycore::paint::{self as engine, Config as PaintConfig, Edition, Ink, Paint};4use mrlycore::state::{randint, seed};5use mrlycore::tile::Tile;6use serde::{Deserialize, Serialize};78/// One rendering of an artwork, sized in tile repetitions.9#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]10pub struct File {11    /// The count of tile repetitions across.12    pub width: usize,13    /// The count of tile repetitions down.14    pub height: usize,15    /// The encoded PNG bytes, empty until rendered and left out of the json.16    #[serde(skip)]17    pub png: Vec<u8>,18}1920impl File {21    /// Builds a file of the given repetition counts with no PNG bytes.22    pub fn new(width: usize, height: usize) -> File {23        File {24            width,25            height,26            png: Vec::new(),27        }28    }29}3031/// The settings an artwork is drawn under.32#[derive(Clone, Debug)]33pub struct Config {34    /// The constraints the tile is drawn under.35    pub tile: tile2d::Config,36    /// The constraints the paint is drawn under.37    pub paint: PaintConfig,38    /// The width and height repetition pairs to render.39    pub files: Vec<(usize, usize)>,40}4142impl Default for Config {43    fn default() -> Config {44        Config {45            tile: tile2d::Config::default(),46            paint: PaintConfig::default(),47            files: vec![(1, 1), (3, 3), (5, 5)],48        }49    }50}5152/// One seeded artwork, from tile recipe to rendered files.53#[derive(Clone, Debug, Serialize, Deserialize)]54pub struct Variation {55    /// The random hex identifier.56    pub key: String,57    /// The seed the variation is drawn under.58    pub seed: u64,59    /// The paint edition.60    pub edition: Edition,61    /// The primary inks, when the config fixes them.62    pub primaries: Option<Vec<Ink>>,63    /// The tile recipe.64    pub tile: Tile,65    /// The mask tile, present only under the Neighbors edition.66    pub mask: Option<Tile>,67    /// The paint, set by generate.68    pub paint: Option<Paint>,69    /// The built base cell, set by generate and left out of the json.70    #[serde(skip)]71    pub base: Option<two::Cell2d>,72    /// The renderings, filled by render.73    pub files: Vec<File>,74}7576impl Variation {77    /// Returns whether the edition paints the whole tiled canvas.78    pub fn is_cover(&self) -> bool {79        matches!(80            self.edition,81            Edition::Rows | Edition::Columns | Edition::Random82        )83    }84    /// Returns whether the edition paints the base cell before tiling.85    pub fn is_prime(&self) -> bool {86        matches!(self.edition, Edition::Layers | Edition::Neighbors)87    }88}8990fn hex_key(length: usize) -> String {91    const DIGITS: &[u8; 16] = b"0123456789abcdef";92    (0..length)93        .map(|_| DIGITS[randint(0, 15) as usize] as char)94        .collect()95}9697fn pop_center(tile: &Tile, cell: &mut two::Cell2d) {98    let _ = tile;99    let center = cell.width() / 2;100    let mut types = cell.cell.types.clone();101    types.set(&[center, center], 0);102    cell.cell.types = types;103}104105/// Draws a fresh seeded variation under the config, with a mask when the edition is Neighbors.106pub fn create(config: &Config) -> Result<Variation> {107    let s = randint(0, i64::MAX) as u64;108    seed(s);109    let edition = engine::random_edition(config.paint.editions.as_deref());110    let tile = tile2d::create(&config.tile)?;111    let mask = if edition == Edition::Neighbors {112        let mask_config = tile2d::Config {113            min_size: 3,114            max_size: 3,115            ..tile2d::Config::default()116        };117        Some(tile2d::create(&mask_config)?)118    } else {119        None120    };121    Ok(Variation {122        key: hex_key(8),123        seed: s,124        edition,125        primaries: config.paint.primaries.clone(),126        tile,127        mask,128        paint: None,129        base: None,130        files: config.files.iter().map(|&(w, h)| File::new(w, h)).collect(),131    })132}133134/// Builds the variation's base cell and paint, painting the base under a prime edition.135pub fn generate(mut variation: Variation, config: &Config) -> Result<Variation> {136    let _ = config;137    let mut base = tile2d::build(&variation.tile)?;138    let paint_config = PaintConfig {139        editions: Some(vec![variation.edition]),140        primaries: variation.primaries.clone(),141        target: None,142    };143    if variation.is_cover() {144        let p = engine::setup(Paint::new(variation.edition), &paint_config);145        variation.paint = Some(p);146    } else {147        let mask_tensor = match &variation.mask {148            Some(mask_tile) => {149                let mut mask_cell = tile2d::build(mask_tile)?;150                pop_center(mask_tile, &mut mask_cell);151                Some(mask_cell.cell.types.clone())152            }153            None => None,154        };155        let mut cell = base.cell.clone();156        let p = engine::paint(&mut cell, &paint_config, mask_tensor.as_ref())?;157        base.cell = cell;158        variation.paint = Some(p);159    }160    variation.base = Some(base);161    Ok(variation)162}163164/// Renders every file of the variation to PNG at the given scale, or an error before generate.165pub fn render(mut variation: Variation, scale: usize) -> Result<Variation> {166    let base = match &variation.base {167        Some(base) => base.clone(),168        None => return value_error("call generate before render."),169    };170    let paint = variation171        .paint172        .clone()173        .ok_or_else(|| MrlyError::Value("call generate before render.".into()))?;174    let cover = variation.is_cover();175    let mut files = std::mem::take(&mut variation.files);176    for file in files.iter_mut() {177        let mut canvas = base.clone().tile(file.width, file.height);178        if cover {179            let mut cell = canvas.cell.clone();180            engine::apply(&paint, &mut cell)?;181            canvas.cell = cell;182        }183        file.png = two::png(&canvas, scale)?;184    }185    variation.files = files;186    Ok(variation)187}188189#[cfg(test)]190mod tests {191    use super::*;192    use mrlycore::json;193    use mrlycore::state::guard;194    use mrlycore::tile::Parity;195    fn round_trip(variation: &Variation) -> Variation {196        serde_json::from_value(serde_json::to_value(variation).unwrap()).unwrap()197    }198    fn config() -> Config {199        Config {200            tile: tile2d::Config {201                min_size: 3,202                max_size: 27,203                anti: Some(false),204                ..tile2d::Config::default()205            },206            paint: PaintConfig::default(),207            files: vec![(1, 1), (3, 3)],208        }209    }210    fn edition_config(edition: Edition) -> Config {211        Config {212            paint: PaintConfig {213                editions: Some(vec![edition]),214                ..PaintConfig::default()215            },216            ..config()217        }218    }219    fn png_size(png: &[u8]) -> (usize, usize) {220        let w = u32::from_be_bytes(png[16..20].try_into().unwrap()) as usize;221        let h = u32::from_be_bytes(png[20..24].try_into().unwrap()) as usize;222        (w, h)223    }224    fn bare_png(variation: &Variation, file: &File, scale: usize) -> Vec<u8> {225        let base = variation.base.as_ref().unwrap();226        let bare = two::Cell2d::new(base.types().clone()).tile(file.width, file.height);227        two::png(&bare, scale).unwrap()228    }229    #[test]230    fn full_pipeline_produces_png_bytes() {231        let _g = guard();232        for s in 0..20 {233            seed(s);234            let v = create(&config()).unwrap();235            let v = generate(v, &config()).unwrap();236            let v = render(v, 4).unwrap();237            assert_eq!(v.files.len(), 2);238            for file in &v.files {239                assert!(240                    !file.png.is_empty(),241                    "empty png for {}x{}",242                    file.width,243                    file.height244                );245                assert_eq!(&file.png[1..4], b"PNG", "not a png header");246                let expected = (247                    v.tile.width * file.width * 4,248                    v.tile.height * file.height * 4,249                );250                assert_eq!(png_size(&file.png), expected, "png size seed {s}");251            }252        }253    }254    #[test]255    fn variation_is_seeded() {256        let _g = guard();257        seed(42);258        let a = create(&config()).unwrap();259        seed(42);260        let b = create(&config()).unwrap();261        assert_eq!(a.seed, b.seed);262        assert_eq!(a.key, b.key);263        assert_eq!(a.tile, b.tile);264        assert_eq!(a.edition, b.edition);265    }266    #[test]267    fn editions_keep_their_palette() {268        let _g = guard();269        for (i, edition) in Edition::all().into_iter().enumerate() {270            let config = edition_config(edition);271            let mut differed = false;272            for s in 0..8 {273                seed(1000 * (i as u64 + 1) + s);274                let v = create(&config).unwrap();275                let v = generate(v, &config).unwrap();276                let v = render(v, 2).unwrap();277                if v.files.iter().all(|f| f.png != bare_png(&v, f, 2)) {278                    differed = true;279                    break;280                }281            }282            assert!(differed, "edition {edition:?} never rendered its palette");283        }284    }285    #[test]286    fn layers_edition_keeps_its_palette() {287        let _g = guard();288        let config = edition_config(Edition::Layers);289        seed(7);290        let v = create(&config).unwrap();291        assert_eq!(v.edition, Edition::Layers);292        let v = generate(v, &config).unwrap();293        assert!(v.base.as_ref().unwrap().cell.colors.is_some());294        let v = render(v, 2).unwrap();295        for file in &v.files {296            assert_ne!(file.png, bare_png(&v, file, 2), "default mapping leaked");297        }298    }299    #[test]300    fn neighbors_edition_gets_a_mask() {301        let _g = guard();302        let config = edition_config(Edition::Neighbors);303        seed(3);304        let v = create(&config).unwrap();305        assert_eq!(v.edition, Edition::Neighbors);306        assert!(v.mask.is_some());307        let v = generate(v, &config).unwrap();308        assert!(v.base.as_ref().unwrap().cell.colors.is_some());309        let v = render(v, 2).unwrap();310        for file in &v.files {311            assert_ne!(file.png, bare_png(&v, file, 2), "default mapping leaked");312        }313    }314    #[test]315    fn neighbors_mask_builds_under_evens_parity() {316        let _g = guard();317        let config = Config {318            tile: tile2d::Config {319                min_size: 4,320                max_size: 16,321                parity: Parity::Evens,322                anti: Some(false),323                ..tile2d::Config::default()324            },325            paint: PaintConfig {326                editions: Some(vec![Edition::Neighbors]),327                ..PaintConfig::default()328            },329            files: vec![(1, 1)],330        };331        for s in 0..10 {332            seed(s);333            let v = create(&config).unwrap();334            let mask = v.mask.as_ref().unwrap();335            assert_eq!((mask.width, mask.height), (3, 3));336            let v = generate(v, &config).unwrap();337            let v = render(v, 2).unwrap();338            assert!(!v.files[0].png.is_empty());339        }340    }341    #[test]342    fn json_round_trips_the_record() {343        let _g = guard();344        for (i, edition) in Edition::all().into_iter().enumerate() {345            let config = edition_config(edition);346            seed(500 + i as u64);347            let a = create(&config).unwrap();348            let b = round_trip(&a);349            assert_eq!(b.key, a.key);350            assert_eq!(b.seed, a.seed);351            assert_eq!(b.edition, a.edition);352            assert_eq!(b.primaries, a.primaries);353            assert_eq!(b.tile, a.tile);354            assert_eq!(b.mask, a.mask);355            assert_eq!(b.paint, a.paint);356            seed(a.seed);357            let a = generate(a, &config).unwrap();358            let a = render(a, 2).unwrap();359            seed(b.seed);360            let b = generate(b, &config).unwrap();361            let b = render(b, 2).unwrap();362            assert_eq!(a.paint, b.paint);363            for (fa, fb) in a.files.iter().zip(&b.files) {364                assert_eq!((fa.width, fa.height), (fb.width, fb.height));365                assert!(!fa.png.is_empty());366                assert_eq!(fa.png, fb.png, "edition {edition:?}");367            }368            let c = round_trip(&a);369            assert_eq!(c.paint, a.paint);370            assert!(c.base.is_none());371            assert!(c.files.iter().all(|f| f.png.is_empty()));372        }373    }374    #[test]375    fn variation_json_rejects_garbage() {376        assert!(serde_json::from_value::<Variation>(json!({})).is_err());377        assert!(serde_json::from_value::<File>(json!({ "width": 2 })).is_err());378    }379    #[test]380    fn json_round_trips_tile() {381        let _g = guard();382        seed(5);383        let v = create(&config()).unwrap();384        let json = serde_json::to_value(&v).unwrap();385        assert!(json.get("base").is_none());386        let back: Tile = serde_json::from_value(json["tile"].clone()).unwrap();387        assert_eq!(back, v.tile);388    }389}