diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 10f0dbffb..8c06ef4c7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -666,7 +666,7 @@ pub fn effect_catalog() -> Vec { "texture.texturizer", "Texturizer…", TEXTURE, - "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief", + "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}), ), r( diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 67f6e6881..2e2325173 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -40,7 +40,7 @@ pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture}; pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture}; pub use pixelate::{MEZZOTINT_TYPES, Mezzotint}; pub use sketch::{HALFTONE_TYPES, HalftonePattern}; -pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture}; +pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). pub const PIXEL_EFFECTS: [&str; 48] = [ @@ -495,7 +495,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0), }, "texture.texturizer" => PixelFx::Texturizer { - texture: Texture::parse(text(p, "texture", "canvas")), + texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)), scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0, relief: num(p, "relief", 4.0).clamp(0.0, 50.0), light: Light::parse(text(p, "lightDirection", "top")), @@ -697,7 +697,7 @@ impl PixelFx { PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten), PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief), PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light), - PixelFx::Texturizer { texture, scaling, relief, light, invert } => { + PixelFx::Texturizer { ref texture, scaling, relief, light, invert } => { texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert) } PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate), diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 255a55739..c053bcd47 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -1426,6 +1426,32 @@ fn texturizer_lights_each_surface_from_its_side() { assert_eq!(big, run(json!({"texture": "brick", "relief": 20}))); } +#[test] +fn texturizer_draws_a_loaded_texture_as_relief() { + let w = 64; + let mid = grey(150); + let rings = stored(12, 10, |x, y| { + let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs(); + let g = (v * 255.0).round() as u8; + [g, g, g, 255] + }); + let run = |p: serde_json::Value| { + let mut d = flat(w, mid); + fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w)); + d + }; + let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20})); + assert_ne!(relief, flat(w, mid)); + for (_, builtin) in TEXTURES { + assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}"); + } + assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid)); + assert_eq!( + fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), + fx("texture.texturizer", json!({})) + ); +} + /// The Texture filters' patterns lie in document space around the object's centre: the same at /// twice the resolution, and moving with the object. #[test] diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index 18c95681d..a40845453 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -7,9 +7,11 @@ //! whose slopes, taken in document units, are lit from one side. use std::f64::consts::{FRAC_1_SQRT_2, PI}; +use std::sync::Arc; use vectorcraft_geom::{Point, Vec2}; +use super::loaded::{LOADED, LoadedTexture, texture}; use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise}; use super::{PixelSpace, blur_plane, pick}; @@ -65,13 +67,19 @@ impl Grain { /// Texturizer's textures, in its Texture menu's order: (label, parameter value). pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")]; -/// A Texturizer surface ([`TEXTURES`]), made in code. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Texturizer's Texture menu once a texture is loaded: [`TEXTURES`], then the loaded one. +pub const TEXTURES_LOADED: [(&str, &str); 5] = + [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone"), ("Loaded Texture", LOADED)]; + +/// A Texturizer surface ([`TEXTURES`]), made in code or loaded from an image. +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Texture { Brick, Burlap, Canvas, Sandstone, + /// An image the user loaded ([`LoadedTexture`]). + Loaded(Arc), } impl Texture { @@ -82,13 +90,23 @@ impl Texture { pick(&TEXTURES, &Self::ALL, value, Texture::Canvas) } + /// The texture of `value` with `stored` the effect's `textureImage`: the loaded texture for + /// [`LOADED`] (any case) when `stored` holds one, else as [`Texture::parse`]. + pub fn parse_with(value: &str, stored: Option<&str>) -> Self { + match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) { + Some(t) => Texture::Loaded(t), + None => Self::parse(value), + } + } + /// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most. - pub(super) fn height(self, p: Point) -> f64 { + pub(super) fn height(&self, p: Point) -> f64 { match self { Texture::Brick => brick(p), Texture::Burlap => weave(p, 7.0, true), Texture::Canvas => weave(p, 4.0, false), Texture::Sandstone => sandstone(p), + Texture::Loaded(t) => t.height(p), } } } @@ -556,7 +574,7 @@ pub(super) fn texturizer( w: usize, h: usize, space: &PixelSpace, - texture: Texture, + texture: &Texture, scaling: f64, relief: f64, light: Light, diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 92de1c83c..8006b6679 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -145,7 +145,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if id == effects::CROP_MARKS && params.get("style").is_none() { params["style"] = json!(s.crop_mark_style().id()); } - if id == GLASS { + if texture_load_effect(id) { load_texture(&mut params, C)?; } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; @@ -321,7 +321,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { super::params::check_effect_color(C, effect, ¶ms)?; super::params::check_effect_channel(C, effect, ¶ms)?; } - if edited.iter().any(|e| e == GLASS) { + if edited.iter().any(|e| texture_load_effect(e)) { load_texture(&mut params, C)?; } for id in appearance_targets(s, p)? { @@ -374,6 +374,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { /// Effect › Distort › Glass. const GLASS: &str = "distort.glass"; +/// Effect › Texture › Texturizer. +const TEXTURIZER: &str = "texture.texturizer"; + +fn texture_load_effect(id: &str) -> bool { + id == GLASS || id == TEXTURIZER +} /// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?, /// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index 313d7fe95..e46b5eef6 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -336,6 +336,29 @@ fn glass_refuses_a_texture_that_is_not_an_image() { assert_eq!(glass_params(&s, g), before); } +/// Effect › Texture › Texturizer › Load Texture: an image is stored with the effect and drawn in relief. +#[test] +fn texturizer_loads_a_texture_and_draws_it() { + let png = rings_png(); + let data = vectorcraft_format::base64_encode(&png); + let (mut s, g) = striped(); + let file = json!({"name": "rings.png", "dataBase64": data}); + s.execute("effect.apply", &json!({"effect": "texture.texturizer", "ids": [g], "params": {"relief": 20, "textureImage": file}})).unwrap(); + let p = glass_params(&s, g); + assert_eq!(p["texture"], json!("loaded")); + let loaded = render(&s); + assert!(glass_params(&s, g)["textureImage"].as_str().is_some()); + for (_, builtin) in vectorcraft_render::effects::pixel::TEXTURES { + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": builtin}})).unwrap(); + assert!(mean_diff(&loaded, &render(&s)) > 0.001, "{builtin}"); + } + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": "loaded"}})).unwrap(); + assert_eq!(render(&s).pixels, loaded.pixels); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g)); + assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels); +} + #[test] fn spin_blur_reaches_past_the_corners() { let (mut s, g) = striped(); diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index dad882e38..c50777892 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -156,7 +156,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()), None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d), - None if id == GLASS => glass_fields(app, ui, d), + None if id == GLASS => glass_fields(app, ui, d, GLASS), + None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()), None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()), @@ -219,20 +220,26 @@ fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// The Glass effect (Effect › Distort › Glass…). const GLASS: &str = "distort.glass"; +/// The Texturizer effect (Effect › Texture › Texturizer…). +const TEXTURIZER: &str = "texture.texturizer"; -/// Glass's dialog: its parameters, the Texture menu ending with Loaded Texture once a texture is +/// Glass and Texturizer: parameters, the Texture menu ending with Loaded Texture once a texture is /// loaded, and Load Texture… with the loaded texture's size. Returns whether the values differ from /// those last previewed. -fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) -> bool { use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; // The stored texture is shown as its size, not as a text field. let stored = d.fields.remove(TEXTURE_IMAGE); let size = stored.as_ref().and_then(Value::as_str).and_then(texture).map(|t| t.size()); - let menu = glass_textures(size.is_some()); - let doc = vectorcraft_effects::effect_info(GLASS).map(|e| e.params).unwrap_or_default(); + let menu = if id == TEXTURIZER { + texturizer_textures(size.is_some()) + } else { + glass_textures(size.is_some()) + }; + let doc = vectorcraft_effects::effect_info(id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); - let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(GLASS, k) }; - form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(GLASS, k, false), &pick, &|k| doc_rank(doc, k), unit); + let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(id, k) }; + form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(id, k, false), &pick, &|k| doc_rank(doc, k), unit); if let Some(v) = stored { d.fields.insert(TEXTURE_IMAGE.into(), v); } @@ -259,6 +266,12 @@ fn glass_textures(loaded: bool) -> form::Choices { if loaded { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES } } +/// Texturizer's Texture menu, ending with Loaded Texture once a texture is `loaded`. +fn texturizer_textures(loaded: bool) -> form::Choices { + use vectorcraft_effects::pixel::{TEXTURES, TEXTURES_LOADED}; + if loaded { &TEXTURES_LOADED } else { &TEXTURES } +} + /// Glass's Load Texture…: an image file the user picks, read into `textureImage` as Glass stores /// it and picked in the Texture menu. fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { @@ -344,9 +357,10 @@ pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map if let Some(own) = own_choices(effect, key, cmyk) { return Some(own.into_iter().map(|(label, value)| (tl!(label).to_string(), value.to_string())).collect()); } - let options = if effect == GLASS && key == "texture" { + let options = if (effect == GLASS || effect == TEXTURIZER) && key == "texture" { use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; - glass_textures(params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some()) + let loaded = params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some(); + if effect == GLASS { glass_textures(loaded) } else { texturizer_textures(loaded) } } else { choices(effect, key)? }; @@ -772,4 +786,34 @@ mod tests { assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored))); let _ = std::fs::remove_dir_all(&dir); } + + /// Texturizer's Load Texture… matches Glass: pick, Loaded Texture in the menu, OK applies. + #[test] + fn texturizer_load_texture_reads_the_picked_image_into_the_effect() { + let dir = std::env::temp_dir().join(format!("vectorcraft-texturizer-dialog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("texture.png"); + image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let picked = path.to_string_lossy().into_owned(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": TEXTURIZER})).unwrap(); + let paint = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())); + let text = paint(&mut app); + assert!(text.lines().any(|l| l == "Load Texture…") && text.lines().any(|l| l == "Canvas"), "{text}"); + let mut d = app.ui.dialog.take().unwrap(); + load_texture(&mut app, &mut d).unwrap(); + let stored = d.str(vectorcraft_effects::pixel::TEXTURE_IMAGE); + assert_eq!((d.str("texture"), vectorcraft_effects::pixel::texture(&stored).map(|t| t.size())), ("loaded".to_string(), Some((6, 4)))); + app.ui.dialog = Some(d); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + let params = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params; + assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 189b71934..4144581fd 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -46,7 +46,7 @@ its resolution, PDF export warns once per object with the requested and the lowe | Sharpen | Unsharp Mask | 1/1 | — | | Sketch | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | 12/14 (approximate Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Water Paper; Chalk & Charcoal, Graphic Pen, Note Paper and Water Paper defaults unverified) | Conté Crayon, Torn Edges | | Stylize | Glowing Edges | 1/1 | — | -| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | Texturizer's Load Texture | +| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | — | | Video | De-Interlace, NTSC Colors | 2/2 | — | | Effect Gallery | the combined browser and stack dialog | 0/1 | all | diff --git a/docs/gaps.md b/docs/gaps.md index e2f992f1c..8cabe62bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -228,6 +228,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G2 Photoshop-style raster effects and the Effect Gallery +- **Done:** Effect › Texture › Texturizer › Load Texture… (stored grayscale `textureImage`, Loaded Texture in the Texture menu), matching Glass. +- **Missing:** 24 of 57 filters (the remaining 11 Artistic filters and 13 of Sketch's 14), the Effect Gallery dialog. Measured in - **Missing:** 10 of 57 filters (7 Artistic, 2 Sketch and 1 more), the Effect Gallery dialog, Load Texture for Texturizer. Measured in [effects-parity.md](effects-parity.md).