Merge pull request #3070 from nrq6993-a11y/stream/vc-feat3-g2-texturizer-load-texture

G2: Texturizer Load Texture in effect dialog
This commit is contained in:
BFlat
2026-10-21 23:59:12 +07:00
committed by GitHub
9 changed files with 139 additions and 20 deletions
+1 -1
View File
@@ -666,7 +666,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"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(
+3 -3
View File
@@ -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<PixelFx> {
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),
+26
View File
@@ -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]
+22 -4
View File
@@ -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<LoadedTexture>),
}
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,
+8 -2
View File
@@ -145,7 +145,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
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, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
@@ -321,7 +321,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
super::params::check_effect_color(C, effect, &params)?;
super::params::check_effect_channel(C, effect, &params)?;
}
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<Value> {
/// 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
+23
View File
@@ -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();
+53 -9
View File
@@ -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<String, Value>
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!((&params["texture"], &params["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!((&params["texture"], &params["textureImage"]), (&json!("loaded"), &json!(stored)));
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -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 |
+2
View File
@@ -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).