Files
vectorcraft/crates/effects/src/pixel/distort.rs
T
dotCooCoo c586cecf75 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.
2026-10-16 06:16:35 +07:00

220 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Effect › Distort (Diffuse Glow, Glass, Ocean Ripple).
//!
//! Glass and Ocean Ripple move each pixel's content by a shift that a surface decides (the slope
//! of Glass's texture, smooth noise for the ripples), sampling the object bilinearly. The shift is
//! capped, so the object reaches no farther than [`glass_shift`] or [`ripple_shift`] past its
//! bounds. Diffuse Glow brightens the object's highlights towards white and sprinkles it with
//! 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};
const SALT_GLOW: u64 = 120;
const SALT_FROST: u64 = 128;
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, Debug, PartialEq, Eq)]
pub enum GlassTexture {
Blocks,
Canvas,
Frosted,
TinyLens,
/// An image the user loaded ([`LoadedTexture`]).
Loaded(Arc<LoadedTexture>),
}
impl GlassTexture {
const ALL: [GlassTexture; 4] = [GlassTexture::Blocks, GlassTexture::Canvas, GlassTexture::Frosted, GlassTexture::TinyLens];
/// The surface of `value` (a [`GLASS_TEXTURES`] value, any case); Frosted otherwise.
pub fn parse(value: &str) -> Self {
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 {
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),
}
}
}
/// Square glass blocks 10 points across, flat on top with 2.5-point bevels.
fn blocks(p: Point) -> f64 {
const SIZE: f64 = 10.0;
const BEVEL: f64 = 2.5;
let to_edge = |v: f64| {
let t = v.rem_euclid(SIZE);
t.min(SIZE - t)
};
to_edge(p.x).min(to_edge(p.y)).min(BEVEL)
}
/// Frosted glass: two octaves of fine smooth noise (1.2 and 0.5 points).
fn frosted(p: Point) -> f64 {
0.8 * value_noise(SALT_FROST, p.x / 1.2, p.y / 1.2) + 0.2 * value_noise(SALT_FROST + 1, p.x / 0.5, p.y / 0.5)
}
/// Tiny lenses 4 points across in rows and columns, each a shallow dome.
fn tiny_lens(p: Point) -> f64 {
const SIZE: f64 = 4.0;
let r = 0.5 * SIZE;
let off = |v: f64| v.rem_euclid(SIZE) - r;
let d2 = off(p.x).powi(2) + off(p.y).powi(2);
0.5 * r * (1.0 - d2 / (r * r)).max(0.0)
}
/// The longest shift (points) Glass gives at `distortion` (0..20).
pub(super) fn glass_shift(distortion: f64) -> f64 {
0.5 * distortion.clamp(0.0, 20.0)
}
/// The ripples' size (points) for Ripple Size `size` (1..15).
fn ripple_wave(size: f64) -> f64 {
2.0 + size.clamp(1.0, 15.0)
}
/// The longest shift (points) Ocean Ripple gives for Ripple Size `size` (1..15) and Ripple
/// Magnitude `magnitude` (0..20).
pub(super) fn ripple_shift(size: f64, magnitude: f64) -> f64 {
0.03 * magnitude.clamp(0.0, 20.0) * ripple_wave(size)
}
/// How far (points) Diffuse Glow spreads the highlights (the σ of their blur).
pub(super) const GLOW_SPREAD: f64 = 2.0;
/// Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2), bent by
/// `distortion` (0..20) and smoothed by `smoothness` (1..15); `invert` turns the surface's heights
/// over.
#[allow(clippy::too_many_arguments)] // the effect's five options
pub(super) fn glass(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
distortion: f64,
smoothness: f64,
texture: &GlassTexture,
scaling: f64,
invert: bool,
) {
let Some(place) = Place::new(space, w, h) else { return };
if !(scaling.is_finite() && scaling > 0.0 && smoothness.is_finite()) {
return;
}
// Slopes over a quarter point of the surface per step of Smoothness, or half a pixel where
// pixels are larger: a smoother glass bends the image more gently.
let step = (0.25 * smoothness.clamp(1.0, 15.0)).max(0.5 * place.px / scaling);
let gain = 0.25 * distortion.clamp(0.0, 20.0) * if invert { -1.0 } else { 1.0 };
displace(px, w, h, &place, glass_shift(distortion), |q| {
let t = Point::new(q.x / scaling, q.y / scaling);
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
Vec2::new(at(step, 0.0) - at(-step, 0.0), at(0.0, step) - at(0.0, -step)) * (gain / (2.0 * step))
});
}
/// Ocean Ripple: the object as if under rippling water, ripples about `size` (1..15) points plus
/// two across, shifting it by up to [`ripple_shift`].
pub(super) fn ocean_ripple(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, magnitude: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let (wave, most) = (ripple_wave(size), ripple_shift(size, magnitude));
displace(px, w, h, &place, most, |q| {
let n = |salt: u64| 0.7 * smooth_noise(salt, q, wave) + 0.3 * smooth_noise(salt + 2, q, 0.5 * wave);
Vec2::new(n(SALT_RIPPLE), n(SALT_RIPPLE + 1)) * most
});
}
/// Diffuse Glow: the object as seen through a soft diffusion filter. Its highlights, softened over
/// [`GLOW_SPREAD`], glow white as strongly as `glow` (0..20) says, from the brightness that
/// `clear` (0..20) leaves clear of glow; white grain as dense as `graininess` (0..10) says is
/// sprinkled over it, thicker in the glow.
pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, graininess: f64, glow: f64, clear: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let mut light: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
blur_plane(&mut light, w, h, GLOW_SPREAD / place.px);
let amount = (glow / 20.0).clamp(0.0, 1.0) as f32;
let from = (0.9 * clear / 20.0).clamp(0.0, 0.9) as f32;
let grain = (graininess / 10.0).clamp(0.0, 1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let g = ((light.get(y * w + x).copied().unwrap_or(0.0) - from) / (1.0 - from)).clamp(0.0, 1.0);
let chance = 0.5 * (cell_noise(SALT_GLOW, place.at(x, y), 1.0, 0) + 1.0);
let dot = if chance < grain * (0.08 + 0.32 * f64::from(g)) { 0.55 } else { 0.0 };
let white = (amount * g + dot).min(1.0);
*p = premultiply(straight(*p).map(|v| v + (1.0 - v) * white), f32::from(p[3]) / 255.0);
});
}
/// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the
/// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it,
/// interpolated between pixels, transparent beyond the raster.
pub(super) fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) {
if !(most.is_finite() && most > 0.0) {
return;
}
let src = px.to_vec();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let d = shift(q);
if !(d.x.is_finite() && d.y.is_finite()) {
return;
}
let len = d.hypot();
let d = if len > most { d * (most / len) } else { d };
let s = place.to_px * (q + d);
*p = sample(&src, w, h, s.x - 0.5, s.y - 0.5);
});
}
/// The premultiplied colour at (`x`, `y`) of `w` × `h` pixels (pixel centres at whole
/// coordinates), interpolated between the four around it, transparent outside.
fn sample(src: &[[u8; 4]], w: usize, h: usize, x: f64, y: f64) -> [u8; 4] {
if !(x.is_finite() && y.is_finite()) {
return [0; 4];
}
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let at = |i: f64, j: f64| -> [f64; 4] {
if i < 0.0 || j < 0.0 || i >= w as f64 || j >= h as f64 {
return [0.0; 4];
}
src.get(j as usize * w + i as usize).map_or([0.0; 4], |p| p.map(f64::from))
};
let (a, b, c, d) = (at(x0, y0), at(x0 + 1.0, y0), at(x0, y0 + 1.0), at(x0 + 1.0, y0 + 1.0));
std::array::from_fn(|k| {
let top = a[k] + (b[k] - a[k]) * fx;
let bottom = c[k] + (d[k] - c[k]) * fx;
(top + (bottom - top) * fy).round().clamp(0.0, 255.0) as u8
})
}