Merge pull request #7712 from dotCooCoo/feat/rough-pastels-load-texture

Effects: Rough Pastels loads its texture from an image file
This commit is contained in:
BFlat
2026-11-15 09:47:09 +07:00
committed by GitHub
14 changed files with 394 additions and 58 deletions
+1 -1
View File
@@ -417,7 +417,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"artistic.roughPastels",
"Rough Pastels…",
ARTISTIC,
"{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.",
"{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), 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)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. 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. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.",
json!({
"strokeLength": 6.0,
"strokeDetail": 10.0,
+4 -3
View File
@@ -155,8 +155,9 @@ pub enum PixelFx {
/// `texture_coverage` (0..40) says.
Underpainting { brush_size: f64, texture_coverage: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Artistic › Rough Pastels: diagonal pastel strokes `length` (0..40) long, bristle detail
/// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as
/// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
/// (1..20), then a [`Texture`] surface (a loaded one included) scaled by `scaling` (0.5..2), in
/// relief as strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's
/// heights over.
RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Blur › Radial Blur around its Blur Center, `center` (0..1 across and down the object's
/// bounds; (0.5, 0.5) the object's centre): along arcs (spin, an arc of `amount`°) or along
@@ -385,7 +386,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"artistic.roughPastels" => PixelFx::RoughPastels {
length: num(p, "strokeLength", 6.0).clamp(0.0, 40.0),
detail: num(p, "strokeDetail", 10.0).clamp(1.0, 20.0),
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")),
+36
View File
@@ -1547,6 +1547,42 @@ fn texturizer_draws_a_loaded_texture_as_relief() {
assert_eq!(fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("texture.texturizer", json!({})));
}
/// Rough Pastels draws a loaded texture as its surface, and Scaling, Relief, Light Direction and
/// Invert act on it as on the built-in ones.
#[test]
fn rough_pastels_draws_a_loaded_texture_as_its_surface() {
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 = |mut p: serde_json::Value| {
p["strokeLength"] = json!(0);
let mut d = flat(w, mid);
fx("artistic.roughPastels", 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}");
}
for (key, value) in [("invert", json!(true)), ("scaling", json!(200)), ("lightDirection", json!("bottomRight")), ("relief", json!(5))] {
let mut p = json!({"texture": "loaded", "textureImage": rings, "relief": 20});
p[key] = value;
assert_ne!(run(p), relief, "{key}");
}
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid));
let parsed = fx("artistic.roughPastels", json!({"texture": "Loaded", "textureImage": rings}));
assert!(matches!(parsed, PixelFx::RoughPastels { texture: Texture::Loaded(_), .. }), "{parsed:?}");
// Without a stored texture, or with one that isn't, Loaded draws Canvas.
for p in [json!({"texture": "loaded"}), json!({"texture": "loaded", "textureImage": "not a texture"})] {
assert_eq!(fx("artistic.roughPastels", p), fx("artistic.roughPastels", 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]
+4 -2
View File
@@ -835,11 +835,13 @@ pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const GLASS: &str = "distort.glass";
/// Effect › Texture › Texturizer.
const TEXTURIZER: &str = "texture.texturizer";
/// Effect › Artistic › Rough Pastels.
const ROUGH_PASTELS: &str = "artistic.roughPastels";
/// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image
/// file (Glass, Texturizer)?
/// file (Glass, Texturizer, Rough Pastels)?
pub(crate) fn texture_load_effect(id: &str) -> bool {
id == GLASS || id == TEXTURIZER
id == GLASS || id == TEXTURIZER || id == ROUGH_PASTELS
}
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
+3 -3
View File
@@ -424,9 +424,9 @@ fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> {
/// A value of the type effect `effect` reads for `key`, a parameter its params description names
/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`,
/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number
/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG
/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their
/// own; they aren't listed.
/// and a Gaussian Blur's `linear` is true or false. A Glass, Texturizer or Rough Pastels
/// `textureImage`, an SVG filter's `name` and `xml`, a Warp's `version` and the `style` of Crop
/// Marks have checks of their own; they aren't listed.
pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option<Value> {
use vectorcraft_render::effects;
let number = || Some(Value::from(0.0));
+2 -2
View File
@@ -538,8 +538,8 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value>
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own
/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass,
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
/// host read one.
/// Texturizer, Rough Pastels) comes as the effect stores it, never as a file to read: a plug-in
/// can't have the host read one.
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
admit_effects_for("plugin.run", fx, n)
}
+1
View File
@@ -1700,6 +1700,7 @@ fn filters_set_effects_and_stroke_styles() {
("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"),
("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.file3", json!([{"id": "artistic.roughPastels", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"),
] {
let e = filter(&mut s, name, fx).unwrap_err().to_string();
+49 -3
View File
@@ -323,8 +323,7 @@ fn glass_loads_a_texture_saves_it_and_draws_it() {
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);
// The same file by path, through Effect Options, stores the same texture.
let dir = std::env::temp_dir().join(format!("vc-glass-texture-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = vectorcraft_testkit::TempDir::new("glass-texture");
let path = dir.join("rings.png");
std::fs::write(&path, &png).unwrap();
let (mut t, h) = striped();
@@ -334,7 +333,7 @@ fn glass_loads_a_texture_saves_it_and_draws_it() {
assert_eq!(glass_params(&t, h)["textureImage"], json!(stored));
assert_eq!(glass_params(&t, h)["texture"], json!("loaded"));
assert_eq!(render(&t).pixels, loaded.pixels);
let _ = std::fs::remove_dir_all(&dir);
drop(dir);
// The stored string is taken as it is (as effect.list gives it, and a graphic style copies it).
t.execute("effect.apply", &json!({"effect": "distort.glass", "ids": [h], "params": {"textureImage": stored, "texture": "frosted"}})).unwrap();
let second = t.doc().unwrap().doc.node(NodeId(h)).unwrap().appearance.effects[1].params.clone();
@@ -391,6 +390,53 @@ fn texturizer_loads_a_texture_and_draws_it() {
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels);
}
/// Effect › Artistic › Rough Pastels › Load Texture: an image file, given as bytes or by path, is
/// stored with the effect and picked; it draws as the surface, a save and open keeps it, a file
/// that isn't an image is refused without a change, and undo takes it away.
#[test]
fn rough_pastels_loads_a_texture_saves_it_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": "artistic.roughPastels", "ids": [g], "params": {"relief": 20, "textureImage": file}})).unwrap();
let p = glass_params(&s, g);
assert_eq!(p["texture"], json!("loaded"), "loading a texture picks it");
let stored = p["textureImage"].as_str().unwrap().to_string();
assert_eq!(vectorcraft_render::effects::pixel::texture(&stored).unwrap().size(), (12, 10));
let loaded = render(&s);
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, "the texture stayed with the effect");
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);
// A file that isn't an image changes nothing.
let junk = vectorcraft_format::base64_encode(b"not an image");
let before = glass_params(&s, g);
let e = s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"textureImage": {"dataBase64": junk}}})).unwrap_err();
assert!(e.to_string().contains("textureImage"), "{e}");
let e = s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"textureImage": 5}})).unwrap_err();
assert!(e.to_string().contains("textureImage"), "{e}");
assert_eq!(glass_params(&s, g), before);
// The same file by path, through Effect Options, stores the same texture; undo takes it away.
let dir = vectorcraft_testkit::TempDir::new("rough-pastels-texture");
let path = dir.join("rings.png");
std::fs::write(&path, &png).unwrap();
let (mut t, h) = striped();
t.execute("effect.apply", &json!({"effect": "artistic.roughPastels", "ids": [h], "params": {"relief": 20}})).unwrap();
assert!(glass_params(&t, h).get("textureImage").is_none());
t.execute("effect.setParams", &json!({"index": 0, "ids": [h], "params": {"textureImage": {"path": path.to_string_lossy()}}})).unwrap();
drop(dir);
assert_eq!((&glass_params(&t, h)["textureImage"], &glass_params(&t, h)["texture"]), (&json!(stored), &json!("loaded")));
assert_eq!(render(&t).pixels, loaded.pixels);
t.execute("edit.undo", &json!({})).unwrap();
assert!(glass_params(&t, h).get("textureImage").is_none(), "undone");
}
#[test]
fn spin_blur_reaches_past_the_corners() {
let (mut s, g) = striped();
+76 -19
View File
@@ -23,7 +23,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
confirm,
preview: true,
// Load Texture… adds the texture it reads.
passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && matches!(d.str("__effect").as_str(), GLASS | TEXTURIZER),
passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && loads_texture(&d.str("__effect")),
// OK could only fail the same way: off, beside the reason the dialog shows (#6140).
ok_enabled: Some(|_, d| !(vectorcraft_effects::is_three_d(&d.str("__effect")) && d.fields.contains_key(form::REFUSED))),
..DialogSpec::FORM
@@ -220,6 +220,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
None if id == GLASS => glass_fields(app, ui, d, GLASS),
None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER),
None if id == ROUGH_PASTELS => glass_fields(app, ui, d, ROUGH_PASTELS),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None if id == SCRIBBLE => scribble_fields(ui, d, app.session.general_unit()),
None if id == ZIG_ZAG || id == ROUGHEN => size_fields(ui, d, &id, app.session.general_unit()),
@@ -413,16 +414,23 @@ fn blur_center(ui: &mut egui::Ui, d: &mut Dialog) {
const GLASS: &str = "distort.glass";
/// The Texturizer effect (Effect › Texture › Texturizer…).
const TEXTURIZER: &str = "texture.texturizer";
/// The Rough Pastels effect (Effect › Artistic › Rough Pastels…).
const ROUGH_PASTELS: &str = "artistic.roughPastels";
/// 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.
/// Does effect `id` have Load Texture… in its dialog (Glass, Texturizer, Rough Pastels)?
fn loads_texture(id: &str) -> bool {
matches!(id, GLASS | TEXTURIZER | ROUGH_PASTELS)
}
/// Glass, Texturizer and Rough Pastels: 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, 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 = if id == TEXTURIZER { texturizer_textures(size.is_some()) } else { glass_textures(size.is_some()) };
let menu = if id == GLASS { glass_textures(size.is_some()) } else { texturizer_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(id, k) };
@@ -438,11 +446,18 @@ fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id:
widgets::dim_label(ui, &format!("{w} × {h} px"));
}
});
if load
&& let Err(e) = crate::picks::in_dialog(app, d, load_texture)
&& e != "cancelled"
{
app.status(e);
if load {
// The Effect Gallery's Settings draw in a dialog of their own: its answer goes to the gallery.
let loaded = if d.fields.contains_key(super::effect_gallery::SETTINGS) {
super::effect_gallery::load_texture(app, d)
} else {
crate::picks::in_dialog(app, d, load_texture)
};
if let Err(e) = loaded
&& e != "cancelled"
{
app.status(e);
}
}
values_changed(d)
}
@@ -453,7 +468,8 @@ 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`.
/// Texturizer's and Rough Pastels' 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 }
@@ -461,7 +477,7 @@ fn texturizer_textures(loaded: bool) -> form::Choices {
/// 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> {
pub(super) fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
use vectorcraft_engine::cmd::fileio;
let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, fileio::read_extensions(f))));
let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?;
@@ -488,8 +504,10 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES),
("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("sketch.basRelief" | "texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "lightDirection") => {
("texture.texturizer" | "artistic.underpainting" | "artistic.roughPastels" | "sketch.conteCrayon", "texture") => {
Some(&vectorcraft_effects::pixel::TEXTURES)
}
("sketch.basRelief" | "texture.texturizer" | "artistic.underpainting" | "artistic.roughPastels" | "sketch.conteCrayon", "lightDirection") => {
Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS)
}
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
@@ -547,10 +565,10 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'
/// The values that parameter `key` of applied effect `effect` picks from, as its dialog lists
/// them, as (label, value): our own effects' labels in the UI language, a plug-in's choices as the
/// plug-in names them. Glass's Texture menu ends with Loaded Texture when the effect's values
/// `params` hold a loaded texture, and the lists of [`own_choices`] are translated as their
/// dialogs translate them (`cmyk`: the document is in CMYK). `None` for a parameter that is not one
/// of these lists.
/// plug-in names them. The Texture menu of Glass, Texturizer and Rough Pastels ends with Loaded
/// Texture when the effect's values `params` hold a loaded texture, and the lists of
/// [`own_choices`] are translated as their dialogs translate them (`cmyk`: the document is in
/// CMYK). `None` for a parameter that is not one of these lists.
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>, cmyk: bool) -> Option<Vec<(String, String)>> {
if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) {
return plugin.manifest().param(key).and_then(|p| match &p.spec {
@@ -561,7 +579,7 @@ 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 || effect == TEXTURIZER) && key == "texture" {
let options = if loads_texture(effect) && key == "texture" {
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
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) }
@@ -1185,6 +1203,45 @@ mod tests {
assert_eq!((&params["texture"], &params["textureImage"]), (&json!("loaded"), &json!(stored)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Rough Pastels' Texture and Light Direction are menus, and Load Texture… matches Glass: pick,
/// Loaded Texture in the menu with the texture's size, OK applies, and the Appearance panel's
/// Texture menu lists Loaded Texture too.
#[test]
fn rough_pastels_load_texture_reads_the_picked_image_into_the_effect() {
let dir = std::env::temp_dir().join(format!("vectorcraft-rough-pastels-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": ROUGH_PASTELS})).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.lines().any(|l| l == "Top"), "{text}");
assert!(!text.contains("Loaded Texture") && !text.contains(" px"), "{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);
let text = paint(&mut app);
assert!(text.lines().any(|l| l == "Loaded Texture") && text.lines().any(|l| l == "6 × 4 px"), "{text}");
assert!(!text.contains(&stored[..24]), "the stored text isn't shown");
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 shown = shown_choices(ROUGH_PASTELS, "texture", params.as_object().unwrap(), false).unwrap();
assert_eq!(shown.last().map(|(_, v)| v.as_str()), Some(vectorcraft_effects::pixel::LOADED), "{shown:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Effect > Stylize > Outer Glow: a swatch beside Color shows the effect's colour, typing a
/// hex recolours it, and a double-click opens the Color Picker over the dialog; its OK fills
/// Color, and the effect applies that colour.
+207 -24
View File
@@ -52,6 +52,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
#[derive(Clone)]
struct StackEntry {
/// The row's own number, kept while the row moves (a file dialog's late answer finds it).
id: u64,
effect: String,
visible: bool,
params: Map<String, Value>,
@@ -63,13 +65,20 @@ pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result<Value, String> {
return Err(tl!("Select artwork to apply effects").into());
}
let selected = effects_in_category(0).first().map(|e| e.id).unwrap_or("");
// Each gallery opened has a number of its own, so a file dialog's late answer for one that
// closed never reaches the next.
static OPENED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let opened = OPENED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
app.ui.dialog = Some(Dialog::new(
KIND,
json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false, "preview": true }),
json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false, "preview": true, OPENED_FIELD: opened }),
));
Ok(Value::Null)
}
/// Field of the gallery holding the number it was opened with.
const OPENED_FIELD: &str = "opened";
fn effects_in_category(cat: usize) -> Vec<EffectInfo> {
let path = CATEGORIES.get(cat).map(|(_, p)| *p).unwrap_or(CATEGORIES[0].1);
let mut out: Vec<EffectInfo> =
@@ -92,31 +101,48 @@ fn merged_params(id: &str, stored: &Map<String, Value>) -> Map<String, Value> {
}
}
/// The stack's rows. A row without a number of its own, or with one an earlier row has, gets the
/// next number after the highest one.
fn stack_of(d: &Dialog) -> Vec<StackEntry> {
d.fields
.get("stack")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| {
let effect = row.get("effect").and_then(Value::as_str)?;
let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true);
let params = row
.get("params")
.and_then(Value::as_object)
.cloned()
.map(|m| merged_params(effect, &m))
.unwrap_or_else(|| default_params(effect));
Some(StackEntry { effect: effect.to_string(), visible, params })
})
.collect()
let rows = d.fields.get("stack").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut next = rows.iter().filter_map(|row| row.get("id").and_then(Value::as_u64)).max().map_or(0, |m| m.saturating_add(1));
next = next.max(d.fields.get(NEXT_ROW).and_then(Value::as_u64).unwrap_or(0));
let mut seen = std::collections::HashSet::new();
rows.iter()
.filter_map(|row| {
let effect = row.get("effect").and_then(Value::as_str)?;
let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true);
let params =
row.get("params").and_then(Value::as_object).cloned().map(|m| merged_params(effect, &m)).unwrap_or_else(|| default_params(effect));
let id = match row.get("id").and_then(Value::as_u64) {
Some(id) if seen.insert(id) => id,
_ => {
let id = next;
next = next.saturating_add(1);
seen.insert(id);
id
}
};
Some(StackEntry { id, effect: effect.to_string(), visible, params })
})
.unwrap_or_default()
.collect()
}
/// Field of the gallery holding the number the next new row takes.
const NEXT_ROW: &str = "nextRowId";
/// A number for a new row of `stack` in `d`: after the highest one, and never one that a removed
/// row had.
fn next_row_id(d: &Dialog, stack: &[StackEntry]) -> u64 {
let after = stack.iter().map(|e| e.id).max().map_or(0, |m| m.saturating_add(1));
after.max(d.fields.get(NEXT_ROW).and_then(Value::as_u64).unwrap_or(0))
}
fn write_stack(d: &mut Dialog, stack: &[StackEntry]) {
let rows: Vec<Value> = stack.iter().map(|e| json!({ "effect": e.effect, "visible": e.visible, "params": e.params })).collect();
let rows: Vec<Value> = stack.iter().map(|e| json!({ "id": e.id, "effect": e.effect, "visible": e.visible, "params": e.params })).collect();
d.fields.insert("stack".into(), Value::Array(rows));
let next = next_row_id(d, stack);
d.fields.insert(NEXT_ROW.into(), json!(next));
}
fn category_ix(d: &Dialog) -> usize {
@@ -177,8 +203,79 @@ fn write_param_target(d: &mut Dialog, stack: &mut [StackEntry], stack_sel: usize
}
}
/// Reuse the live effect dialog's fields for `effect_id` and `params`.
fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map<String, Value>) {
/// Field of the effect dialog the gallery's Settings draw in ([`effect_param_fields`]): which
/// values they edit ([`settings_key`]).
pub(super) const SETTINGS: &str = "__gallerySettings";
/// Field of the stand-in dialog [`load_texture`] asks the file dialog with.
const ASKING: &str = "__asking";
/// The stack row the Settings of `d` edit when the stack has the focus, of `rows` rows.
fn settings_row(d: &Dialog, rows: usize) -> Option<usize> {
let row = (d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize).min(rows.saturating_sub(1));
stack_focus(d).then_some(row)
}
/// Which values the Settings of gallery `d` edit: `{opened, row}`, the gallery's number and the
/// number of the stack row, or `null` for the row when they edit the filter being browsed.
fn settings_key(d: &Dialog, stack: &[StackEntry]) -> Value {
let row = settings_row(d, stack.len()).and_then(|row| stack.get(row)).map(|e| e.id);
json!({ "opened": d.fields.get(OPENED_FIELD), "row": row })
}
/// Load Texture… in the gallery's Settings (`temp`, for Glass, Texturizer or Rough Pastels): the
/// image picked goes into `temp` when the file dialog answers at once. When it answers later
/// (shown off the UI thread) it goes into the values that asked: the stack row of that number
/// wherever it moved, or the filter being browsed. An answer for a row that was removed, or for a
/// filter no longer browsed, or for a gallery that closed, is dropped.
pub(super) fn load_texture(app: &mut VectorcraftApp, temp: &mut Dialog) -> Result<(), String> {
use vectorcraft_effects::pixel::TEXTURE_IMAGE;
let effect = temp.str("__effect");
let key = temp.fields.get(SETTINGS).cloned().unwrap_or(Value::Null);
let opened = key.get("opened").cloned().unwrap_or(Value::Null);
let row = key.get("row").and_then(Value::as_u64);
let mut asking = Dialog::new(KIND, json!({ ASKING: true }));
crate::picks::in_dialog(app, &mut asking, move |app, d| {
if d.fields.contains_key(ASKING) {
return effect::load_texture(app, d);
}
let mut picked = Dialog::new(KIND, json!({}));
effect::load_texture(app, &mut picked)?;
if d.fields.get(OPENED_FIELD).unwrap_or(&Value::Null) != &opened {
return Ok(());
}
match row {
Some(row) => {
let mut stack = stack_of(d);
if let Some(e) = stack.iter_mut().find(|e| e.id == row && e.effect == effect) {
e.params.extend(picked.fields);
write_stack(d, &stack);
}
}
None if selected_id(d) == effect => {
let mut params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(effect.as_str()) {
browse_params(d)
} else {
default_params(&effect)
};
params.extend(picked.fields);
set_browse_params(d, &effect, params);
}
None => {}
}
Ok(())
})?;
for key in [TEXTURE_IMAGE, "texture"] {
if let Some(v) = asking.fields.remove(key) {
temp.fields.insert(key.into(), v);
}
}
Ok(())
}
/// Reuse the live effect dialog's fields for `effect_id` and `params`, the values `key` names
/// ([`settings_key`]).
fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map<String, Value>, key: Value) {
if effect_id.is_empty() {
return;
}
@@ -188,6 +285,7 @@ fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &
fields.insert("__effect".into(), json!(effect_id));
fields.insert("__label".into(), json!(label));
fields.insert("preview".into(), json!(false));
fields.insert(SETTINGS.into(), key);
let mut temp = Dialog::new("effect", Value::Object(fields));
(effect::SPEC.body)(app, ui, &mut temp);
if let Value::Object(m) = form::params(&temp) {
@@ -475,8 +573,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel);
if !param_effect.is_empty() {
widgets::subheader(ui, tl!("Settings"));
let key = settings_key(d, &stack);
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
effect_param_fields(app, ui, &param_effect, &mut param_values);
effect_param_fields(app, ui, &param_effect, &mut param_values, key);
});
write_param_target(d, &mut stack, stack_sel, &sel, &param_effect, param_values);
}
@@ -604,7 +703,7 @@ fn add_selected(d: &mut Dialog, id: &str) {
let params =
if !stack_focus(d) && d.fields.get("browseEffect").and_then(Value::as_str) == Some(id) { browse_params(d) } else { default_params(id) };
let mut stack = stack_of(d);
stack.push(StackEntry { effect: id.to_string(), visible: true, params });
stack.push(StackEntry { id: next_row_id(d, &stack), effect: id.to_string(), visible: true, params });
write_stack(d, &stack);
d.fields.insert("stackSelected".into(), json!(stack.len().saturating_sub(1)));
set_stack_focus(d, true);
@@ -739,4 +838,88 @@ mod tests {
);
assert_ne!(soft, hard, "the preview follows the filter's settings");
}
/// Load Texture… in the Settings, with the file dialog shown off the UI thread: the image
/// goes into the values whose Settings asked (the filter browsed, or a stack row wherever it
/// moved) when the dialog answers, and an answer for a removed row, or for a filter no longer
/// browsed, is dropped.
#[test]
fn settings_load_texture_answers_into_the_gallery() {
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
let dir = std::env::temp_dir().join(format!("vectorcraft-gallery-texture-{}", 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 picked = path.to_string_lossy().into_owned();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services);
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();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
app.run("ui.effectGalleryDialog", json!({})).unwrap();
let effect = "artistic.roughPastels";
let loaded = |params: &serde_json::Map<String, serde_json::Value>| {
params.get(TEXTURE_IMAGE).and_then(serde_json::Value::as_str).and_then(texture).map(|t| t.size()) == Some((6, 4))
&& params.get("texture") == Some(&json!("loaded"))
};
// Asks from the Settings of `row` (None: the filter browsed), then `meanwhile` changes the
// gallery before the dialog answers.
let ask = |app: &mut VectorcraftApp, row: Option<usize>, meanwhile: &dyn Fn(&mut crate::state::Dialog)| {
let gallery = app.ui.dialog.as_mut().unwrap();
gallery.fields.remove("browseEffect");
gallery.fields.remove("browseParams");
gallery.fields.insert("selected".into(), json!(effect));
gallery.fields.remove(super::NEXT_ROW);
gallery.fields.insert("stack".into(), json!([{"effect": effect, "params": {}}, {"effect": effect, "params": {}}]));
let rows = super::stack_of(gallery);
super::write_stack(gallery, &rows);
gallery.fields.insert("stackFocus".into(), json!(row.is_some()));
gallery.fields.insert("stackSelected".into(), json!(row.unwrap_or(0)));
let key = super::settings_key(gallery, &super::stack_of(gallery));
let mut temp = crate::state::Dialog::new("effect", json!({"__effect": effect, super::SETTINGS: key, "texture": "canvas"}));
assert_eq!(super::load_texture(app, &mut temp), Err("cancelled".to_string()), "asked, not answered yet");
assert!(temp.fields.get(TEXTURE_IMAGE).is_none());
meanwhile(app.ui.dialog.as_mut().unwrap());
shown.answer(&[picked.as_str()]);
crate::picks::poll(app, &egui::Context::default());
let d = app.ui.dialog.as_ref().expect("the gallery stays open");
(super::browse_params(d), super::stack_of(d).into_iter().map(|e| loaded(&e.params)).collect::<Vec<_>>())
};
// The filter browsed gets it, and so does the stack row whose Settings asked.
let (browsed, rows) = ask(&mut app, None, &|_| {});
assert!(loaded(&browsed) && rows == [false, false], "{browsed:?}");
let (browsed, rows) = ask(&mut app, Some(1), &|_| {});
assert!(!loaded(&browsed) && rows == [false, true], "{rows:?}");
// The row that asked gets it after it moved, or after another row was selected.
let (_, rows) = ask(&mut app, Some(0), &|d| {
let mut stack = super::stack_of(d);
stack.swap(0, 1);
super::write_stack(d, &stack);
});
assert_eq!(rows, [false, true], "moved down");
let (_, rows) = ask(&mut app, Some(0), &|d| {
d.fields.insert("stackSelected".into(), json!(1));
});
assert_eq!(rows, [true, false], "another row selected");
// A row removed (and another of the same effect added in its place), or a filter no
// longer browsed: dropped.
let (browsed, rows) = ask(&mut app, Some(1), &|d| {
let mut stack = super::stack_of(d);
stack.pop();
super::write_stack(d, &stack);
super::add_selected(d, effect);
});
assert!(!loaded(&browsed) && rows == [false, false], "{rows:?}");
let (browsed, rows) = ask(&mut app, None, &|d| {
d.fields.insert("selected".into(), json!("artistic.cutout"));
});
assert!(!loaded(&browsed) && rows == [false, false], "{browsed:?}");
// The gallery that asked closed and another one opened.
let (browsed, rows) = ask(&mut app, Some(1), &|d| {
let next = d.fields.get(super::OPENED_FIELD).and_then(serde_json::Value::as_u64).unwrap() + 1;
d.fields.insert(super::OPENED_FIELD.into(), json!(next));
});
assert!(!loaded(&browsed) && rows == [false, false], "{rows:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ the two can still win that race.
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `<kind>` dialog cannot set protected field `<field>`", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass, Texturizer and Rough Pastels effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `<kind>` dialog cannot set protected field `<field>`", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes |
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
+1
View File
@@ -522,6 +522,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
## 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.
- **Done:** Effect › Artistic › Rough Pastels › Load Texture… ([raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html)): the image's gray levels are the paper surface, stored with the effect, listed as Loaded Texture in the Texture menu, with Scaling, Relief, Light Direction and Invert acting on it.
- **Done:** the Effect Gallery's filter thumbnails and in-dialog preview ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Each thumbnail is an original landscape drawing passed through that filter at its defaults; the preview area shows the same drawing through the visible stack, or through the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are unchanged (`dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`). All 57 Photoshop-style filters are in the catalogue. Measured in
[effects-parity.md](effects-parity.md).
- **Done:** the Effect Gallery (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step (`dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`).
+8
View File
@@ -7324,6 +7324,14 @@ is mixed with middle gray by its alpha.
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"distort.glass","params":{"distortion":10,"textureImage":{"path":"/tmp/texture.png"}}}}}
```
Texturizer (`texture.texturizer`) and Rough Pastels (`artistic.roughPastels`) take `params.textureImage` the same
way, as the surface that Scaling, Relief, Light Direction and Invert act on. With `texture: "loaded"` and no stored
texture they draw Canvas.
```json
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"artistic.roughPastels","params":{"relief":12,"textureImage":{"path":"/tmp/texture.png"}}}}}
```
## Linked images
A placed image linked to its file (`file.place {path}`, Link on) records the file's absolute path, its size,
+1
View File
@@ -2020,6 +2020,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.
- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object 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. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before.
- **Rough Pastels' Load Texture:** Effect › Artistic › Rough Pastels… has the same Load Texture… button, and its Texture and Light Direction controls are menus. The loaded image's gray levels are the paper surface (Loaded Texture in the Texture menu, `textureImage` stored with the effect), and Scaling, Relief, Light Direction and Invert act on it as on Brick, Burlap, Canvas and Sandstone (`pixel::tests::rough_pastels_draws_a_loaded_texture_as_its_surface`, `rough_pastels_loads_a_texture_saves_it_and_draws_it`).
- **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values.
- **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 3), and a file saved for an older app version holds them in points again.