Effects: Glass loads its texture from an image file

Effect › Distort › Glass… offered only its four built-in textures. The
reference app's Texture menu also has Load Texture, which takes an
image file as the glass surface.

The Glass dialog now has a Load Texture… button. It reads a PNG, JPEG,
GIF, WebP, TIFF, BMP, PSD or PSB file within the pixel allowance the
renderer draws images in, and the Texture menu gains Loaded Texture.
The effect stores the image's gray levels in `textureImage` as a
grayscale PNG in base64, so the document saves the texture with the
effect, and copies of the effect carry it. effect.apply and
effect.setParams take `textureImage` as {path}, {name, dataBase64} or
the stored string; any other value is an error.

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. A Glass without `textureImage` draws and saves as before.

Docs: docs/mcp.md (Glass textures), docs/effects-parity.md,
docs/gaps.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 06:16:35 +07:00
committed by bflatastic
parent 7e9d0be723
commit c586cecf75
33 changed files with 615 additions and 19 deletions
+1 -1
View File
@@ -415,7 +415,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.glass",
"Glass…",
DISTORT,
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted 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), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. 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!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
),
r(
+22 -3
View File
@@ -7,8 +7,11 @@
//! white grain. Every surface lies in document space around the object's centre, as the Texture
//! filters' do.
use std::sync::Arc;
use vectorcraft_geom::{Point, Vec2};
use super::loaded::{LOADED, LoadedTexture, texture};
use super::pixelate::{Place, value_noise};
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
@@ -20,13 +23,19 @@ const SALT_RIPPLE: u64 = 136;
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
/// Glass's Texture menu once a texture is loaded: [`GLASS_TEXTURES`], then the loaded one.
pub const GLASS_TEXTURES_LOADED: [(&str, &str); 5] =
[("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens"), ("Loaded Texture", LOADED)];
/// A Glass surface.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GlassTexture {
Blocks,
Canvas,
Frosted,
TinyLens,
/// An image the user loaded ([`LoadedTexture`]).
Loaded(Arc<LoadedTexture>),
}
impl GlassTexture {
@@ -37,13 +46,23 @@ impl GlassTexture {
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
}
/// The surface of `value` with `stored` the effect's `textureImage`: the loaded texture
/// for [`LOADED`] (any case) when `stored` holds one, else as [`GlassTexture::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) => GlassTexture::Loaded(t),
None => Self::parse(value),
}
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
fn height(self, p: Point) -> f64 {
fn height(&self, p: Point) -> f64 {
match self {
GlassTexture::Blocks => blocks(p),
GlassTexture::Canvas => Texture::Canvas.height(p),
GlassTexture::Frosted => frosted(p),
GlassTexture::TinyLens => tiny_lens(p),
GlassTexture::Loaded(t) => t.height(p),
}
}
}
@@ -103,7 +122,7 @@ pub(super) fn glass(
space: &PixelSpace,
distortion: f64,
smoothness: f64,
texture: GlassTexture,
texture: &GlassTexture,
scaling: f64,
invert: bool,
) {
+168
View File
@@ -0,0 +1,168 @@
//! Load Texture (Effect › Distort › Glass): a surface made from an image the user loads.
//!
//! The effect keeps the image's gray levels in its parameters ([`TEXTURE_IMAGE`]: a grayscale PNG,
//! base64), so the document saves the texture with the effect, and copies of the effect carry it.
//! Each pixel's gray is the surface's height there: white stands [`RELIEF`] above black. The image
//! lies one pixel per point at 100 %, centered on the object's center and repeated in both
//! directions.
use std::io::Cursor;
use std::sync::{Arc, Mutex, PoisonError};
use vectorcraft_geom::Point;
use super::texture::luma;
/// The effect parameter that holds a loaded texture: its gray levels as a grayscale PNG, base64.
pub const TEXTURE_IMAGE: &str = "textureImage";
/// The Texture menu's value for the loaded texture.
pub const LOADED: &str = "loaded";
/// How far (points) a loaded texture's white stands above its black: a black pixel beside a white
/// one gives a slope of about 1, the most the built-in surfaces have.
const RELIEF: f64 = 1.0;
/// How many decoded textures [`texture`] keeps.
const KEEP: usize = 4;
/// A loaded texture: `width` × `height` gray levels, row by row from the top.
#[derive(Debug, PartialEq, Eq)]
pub struct LoadedTexture {
width: u32,
height: u32,
gray: Vec<u8>,
}
impl LoadedTexture {
/// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's
/// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray).
/// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels.
pub fn from_rgba(width: u32, height: u32, rgba: &[u8]) -> Option<Self> {
let n = (width as usize).checked_mul(height as usize).filter(|n| *n > 0)?;
let (px, rest) = rgba.as_chunks::<4>();
if px.len() != n || !rest.is_empty() {
return None;
}
let gray = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let l = luma([p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0));
((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8
})
.collect();
Some(Self { width, height, gray })
}
/// The size in pixels.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Its [`TEXTURE_IMAGE`] value: the gray levels as a grayscale PNG, base64; `None` when the
/// PNG can't be written.
pub fn to_stored(&self) -> Option<String> {
let img = image::GrayImage::from_raw(self.width, self.height, self.gray.clone())?;
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(base64_encode(&png))
}
/// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`];
/// `None` when `png` isn't a PNG or holds no pixels.
fn from_png(png: &[u8]) -> Option<Self> {
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
reader.limits(limits);
let img = reader.decode().ok()?.to_luma8();
let (width, height) = img.dimensions();
(width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() })
}
/// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray
/// levels around it interpolated, 0 for black and [`RELIEF`] for white.
pub(super) fn height(&self, p: Point) -> f64 {
let (w, h) = (f64::from(self.width), f64::from(self.height));
// Pixel centers lie at whole coordinates, and the image's center lies at the origin.
let (x, y) = (p.x + 0.5 * w - 0.5, p.y + 0.5 * h - 0.5);
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let at = |i: f64, j: f64| {
let (i, j) = (i.rem_euclid(w) as usize, j.rem_euclid(h) as usize);
self.gray.get(j * self.width as usize + i).map_or(0.5, |g| f64::from(*g) / 255.0)
};
let top = at(x0, y0) + (at(x0 + 1.0, y0) - at(x0, y0)) * fx;
let bottom = at(x0, y0 + 1.0) + (at(x0 + 1.0, y0 + 1.0) - at(x0, y0 + 1.0)) * fx;
RELIEF * (top + (bottom - top) * fy)
}
}
/// The textures [`texture`] decoded last, with their stored form, the newest last.
static DECODED: Mutex<Vec<(String, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
/// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last
/// [`KEEP`] textures decoded are kept for the next call.
pub fn texture(stored: &str) -> Option<Arc<LoadedTexture>> {
let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = decoded.iter().position(|(s, _)| s == stored) {
let entry = decoded.remove(i);
let t = entry.1.clone();
decoded.push(entry);
return Some(t);
}
let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?);
if decoded.len() >= KEEP {
decoded.remove(0);
}
decoded.push((stored.to_string(), t.clone()));
Some(t)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 (RFC 4648, with padding), written as `vectorcraft_format::base64_encode`
/// writes it.
fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
/// Standard base64 (RFC 4648, with padding; whitespace skipped), read as
/// `vectorcraft_format::base64_decode` reads it; `None` when it isn't base64.
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !clean.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
for c in clean.chunks(4) {
let mut n = 0u32;
let mut pad = 0;
for &b in c {
n <<= 6;
if b == b'=' {
pad += 1;
} else {
n |= val(b)?;
}
}
out.push((n >> 16) as u8);
if pad < 2 {
out.push((n >> 8) as u8);
}
if pad < 1 {
out.push(n as u8);
}
}
Some(out)
}
+11 -9
View File
@@ -17,6 +17,7 @@ mod blur;
mod brushstrokes;
mod distort;
mod edges;
mod loaded;
mod pixelate;
mod sharpen;
mod texture;
@@ -29,7 +30,8 @@ use crate::util::{flag, num, text};
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
pub use distort::{GLASS_TEXTURES, GlassTexture};
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 texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
@@ -67,7 +69,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
/// are in document units.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
@@ -111,9 +113,9 @@ pub enum PixelFx {
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
/// `invert`.
/// Distort › Glass: the object seen through a `texture` surface (a built-in one, or an image
/// the user loaded) scaled by `scaling` (0.5..2), bent by `distortion` (0..20), smoothed by
/// `smoothness` (1..15), its heights turned over when `invert`.
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
/// `magnitude` (0..20) says.
@@ -263,7 +265,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"distort.glass" => PixelFx::Glass {
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
texture: GlassTexture::parse(text(p, "texture", "frosted")),
texture: GlassTexture::parse_with(text(p, "texture", "frosted"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
invert: flag(p, "invert", false),
},
@@ -432,7 +434,7 @@ impl PixelFx {
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
PixelFx::Glass { distortion, smoothness, ref texture, scaling, invert } => {
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
@@ -458,8 +460,8 @@ impl PixelFx {
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
/// otherwise.
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
fn pick<T: Clone>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| t.clone())
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
+63 -1
View File
@@ -36,7 +36,7 @@ fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => *p,
Some(RasterFx::Pixel(p)) => p.clone(),
other => panic!("{id}: {other:?}"),
}
}
@@ -1419,6 +1419,68 @@ fn glass_bends_the_image_by_its_texture() {
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
}
/// The stored form of a `w` × `h` texture whose straight RGBA pixels `f` gives.
fn stored(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> String {
LoadedTexture::from_rgba(w as u32, h as u32, &image(w, h, f)).and_then(|t| t.to_stored()).unwrap()
}
#[test]
fn a_loaded_texture_is_its_gray_over_middle_gray() {
let t = stored(3, 1, |x, _| [[255, 255, 255, 255], [0, 0, 0, 0], [255, 0, 0, 255]][x]);
let loaded = texture(&t).unwrap();
assert_eq!(loaded.size(), (3, 1));
assert_eq!(texture(&t), Some(loaded.clone()), "read again from the cache");
// A white pixel is 1 high, a transparent one is middle gray and a red one is its luma. Heights
// between pixel centers are interpolated, and the image repeats: x = -1 is the first pixel's
// center, and x = 2 is the same pixel again.
let h = |x: f64| loaded.height(Point::new(x, 0.0));
assert_eq!(h(-1.0), 1.0);
assert_eq!(h(0.0), 128.0 / 255.0);
assert_eq!(h(1.0), 76.0 / 255.0);
assert_eq!(h(2.0), 1.0);
assert_eq!(h(-0.5), 0.5 * (1.0 + 128.0 / 255.0));
assert!(LoadedTexture::from_rgba(0, 4, &[]).is_none() && LoadedTexture::from_rgba(2, 2, &[0; 12]).is_none());
assert!(texture("not base64!").is_none() && texture("aGVsbG8=").is_none(), "base64 of a PNG only");
}
#[test]
fn glass_bends_the_image_by_a_loaded_texture() {
let (w, h) = (48, 40);
let src = stripes(w, h);
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
d
};
// Rings around the texture's center, 9 × 7 pixels, repeated.
let rings = stored(9, 7, |x, y| grey((((x as f64 - 4.0).hypot(y as f64 - 3.0) * 60.0) as u32 % 256) as u8));
let loaded = json!({"texture": "loaded", "textureImage": rings, "distortion": 12});
let bent = run(loaded.clone());
assert_ne!(bent, src);
for (_, texture) in GLASS_TEXTURES {
assert_ne!(bent, run(json!({"texture": texture, "distortion": 12})), "differs from {texture}");
}
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "invert": true})), bent, "inverted");
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "scaling": 200})), bent, "scaled");
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
let p = at(&bent, w, x, y);
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "at ({x}, {y}): {p:?}");
}
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 0})), src, "no distortion, no change");
// A flat texture bends nothing, whatever its gray; so does a transparent one (middle gray).
for flat in [grey(0), grey(200), [0, 0, 0, 0]] {
assert_eq!(run(json!({"texture": "loaded", "textureImage": stored(5, 5, |_, _| flat), "distortion": 20})), src, "{flat:?}");
}
// The image is kept but not used with a built-in texture; without one, "loaded" is Frosted.
assert_eq!(run(json!({"texture": "blocks", "textureImage": rings, "distortion": 12})), run(json!({"texture": "blocks", "distortion": 12})));
assert_eq!(fx("distort.glass", json!({"texture": "loaded"})), fx("distort.glass", json!({})));
assert_eq!(fx("distort.glass", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("distort.glass", json!({})));
assert_eq!(
fx("distort.glass", loaded),
PixelFx::Glass { distortion: 12.0, smoothness: 3.0, texture: GlassTexture::Loaded(texture(&rings).unwrap()), scaling: 1.0, invert: false }
);
}
#[test]
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
let (w, h) = (64, 32);