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}