Merge pull request #2301 from dotCooCoo/feat/glass-load-texture

Effects: Glass loads its texture from an image file
This commit is contained in:
BFlat
2026-10-16 06:35:05 +07:00
committed by GitHub
37 changed files with 631 additions and 19 deletions
Generated
+1
View File
@@ -4634,6 +4634,7 @@ dependencies = [
name = "vectorcraft-effects"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde_json",
"vectorcraft-color",
+1
View File
@@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-plugins = { workspace = true }
image = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+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);
+32 -1
View File
@@ -142,6 +142,9 @@ 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 {
load_texture(&mut params, C)?;
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
@@ -297,7 +300,7 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
@@ -310,6 +313,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let visible = p.get("visible").and_then(Value::as_bool);
let item = item_target(s, p, C)?;
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
load_texture(&mut params, C)?;
}
for id in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
let target = item.effects_item(&n.appearance, C)?;
@@ -356,6 +362,31 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
// ---------- Load Texture ----------
/// Effect › Distort › Glass.
const GLASS: &str = "distort.glass";
/// 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
/// `params` give a `texture`; a string must be a texture as Glass stores it.
fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
let pick = params.get("texture").is_none();
let Some(v) = params.get_mut(effects::pixel::TEXTURE_IMAGE) else { return Ok(()) };
match v {
Value::String(s) if effects::pixel::texture(s).is_some() => {}
Value::Object(_) => {
let bytes = fileio::source(v, cmd)?.bytes;
*v = json!(fileio::texture_image(&bytes).map_err(|e| bad(cmd, format!("textureImage: {e}")))?);
if pick {
params["texture"] = json!(effects::pixel::LOADED);
}
}
_ => return Err(bad(cmd, "textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it")),
}
Ok(())
}
// ---------- Expand Appearance ----------
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
+38
View File
@@ -5,6 +5,7 @@ use std::io::Cursor;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::*;
use super::pdfimport::LoadOptions;
@@ -491,6 +492,43 @@ fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage>
stored(png, "image/png", size, ppi)
}
/// Effect › Distort › Glass › Load Texture…: encoded image `bytes`, in any format [`raster_image`]
/// reads, as the `textureImage` Glass stores ([`LoadedTexture::to_stored`]). The pixels are decoded
/// within [`MAX_RASTER_ALLOC`], counted as RGBA, as the renderer decodes them (#1717); a CMYK image
/// is converted with the active color settings.
pub fn texture_image(bytes: &[u8]) -> Result<String> {
if let Some(note) = too_large_note(bytes) {
return Err(err(note));
}
let RasterImage { blob, width, height, .. } = raster_image(bytes)?;
// The allowance counts the pixels as RGBA: a gray image takes four times its file's samples (#1717).
if u64::from(width) * u64::from(height) * 4 > MAX_RASTER_ALLOC {
return Err(err(format!("an image of {width} × {height} pixels is too large to draw")));
}
let texture = match blob.cmyk() {
Some(inks) => {
let cms = vectorcraft_color::cms::active();
let rgba: Vec<u8> = (inks.data.as_chunks::<4>().0.iter())
.flat_map(|c| {
let rgb = cms.cmyk_to_srgb(c.map(|v| f32::from(v) / 255.0), false);
let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
[r, g, b, 255]
})
.collect();
LoadedTexture::from_rgba(inks.width, inks.height, &rgba)
}
None => {
let mut reader = image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(err)?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
reader.limits(limits);
let img = reader.decode().map_err(err)?.to_rgba8();
LoadedTexture::from_rgba(img.width(), img.height(), img.as_raw())
}
};
texture.and_then(|t| t.to_stored()).ok_or_else(|| err("the image can't be read as a texture"))
}
/// An image's encoded `bytes`, `width` × `height` pixels, ready to embed.
fn stored(bytes: Vec<u8>, mime: &'static str, (width, height): (u32, u32), ppi: Option<(f64, f64)>) -> Result<RasterImage> {
if width == 0 || height == 0 {
+1 -1
View File
@@ -60,7 +60,7 @@ pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
read_for_open,
read_for_open, texture_image,
};
pub use load::{losses_summary, overwrite_losses};
pub(crate) use native::preview_png;
+88
View File
@@ -150,6 +150,94 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
}
}
/// A PNG of rings around its center, 12 × 10 pixels.
fn rings_png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(12, 10, |x, y| {
let v = ((f64::from(x) - 5.5).hypot(f64::from(y) - 4.5) * 50.0) as u32 % 256;
image::Rgba([v as u8, v as u8, 255 - v as u8, 255])
});
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// Glass's parameters on the striped group.
fn glass_params(s: &Session, g: u64) -> Value {
s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects[0].params.clone()
}
/// Effect › Distort › Glass › Load Texture: an image file, given by path or as bytes, is stored
/// with the effect as its gray levels and picked; the canvas draws it, and a save and open keeps it.
#[test]
fn glass_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": "distort.glass", "ids": [g], "params": {"distortion": 12, "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();
let texture = vectorcraft_render::effects::pixel::texture(&stored).unwrap();
assert_eq!(texture.size(), (12, 10));
let loaded = render(&s);
// The same Glass with each built-in texture draws differently.
for (_, builtin) in vectorcraft_render::effects::pixel::GLASS_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");
// Saved and opened again: the same texture, drawn the same.
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);
// 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 path = dir.join("rings.png");
std::fs::write(&path, &png).unwrap();
let (mut t, h) = striped();
t.execute("effect.apply", &json!({"effect": "distort.glass", "ids": [h], "params": {"distortion": 12}})).unwrap();
assert!(glass_params(&t, h).get("textureImage").is_none(), "a Glass without a loaded texture has no textureImage");
t.execute("effect.setParams", &json!({"index": 0, "ids": [h], "params": {"textureImage": {"path": path.to_string_lossy()}}})).unwrap();
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);
// 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();
assert_eq!((&second["textureImage"], &second["texture"]), (&json!(stored), &json!("frosted")), "kept, not picked: texture was given");
t.execute("edit.undo", &json!({})).unwrap();
t.execute("edit.undo", &json!({})).unwrap();
assert!(glass_params(&t, h).get("textureImage").is_none(), "undone");
}
/// A texture that isn't an image file, or isn't one Glass stored, is refused and changes nothing.
#[test]
fn glass_refuses_a_texture_that_is_not_an_image() {
let (mut s, g) = striped();
let junk = vectorcraft_format::base64_encode(b"not an image");
let not_stored = "textureImage: give {path} or {name, dataBase64}";
let refused = [
(json!({"name": "a.png", "dataBase64": junk}), "not an image VectorCraft reads"),
(json!("bm90IGEgdGV4dHVyZQ=="), not_stored),
(json!(5), not_stored),
(json!({"path": "C:/no/such/texture.png"}), "texture.png"),
];
for (texture, says) in refused {
let e = s.execute("effect.apply", &json!({"effect": "distort.glass", "ids": [g], "params": {"textureImage": texture}})).unwrap_err();
assert!(e.to_string().contains(says), "{texture}: {e}");
assert!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects.is_empty(), "{texture}");
}
s.execute("effect.apply", &json!({"effect": "distort.glass", "ids": [g]})).unwrap();
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}");
assert_eq!(glass_params(&s, g), before);
}
#[test]
fn spin_blur_reaches_past_the_corners() {
let (mut s, g) = striped();
+86
View File
@@ -149,6 +149,7 @@ 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(ui, d, &plugin.manifest().params),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == GLASS => glass_fields(app, ui, d),
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()),
@@ -205,6 +206,56 @@ fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
before != d.fields
}
/// The Glass effect (Effect › Distort › Glass…).
const GLASS: &str = "distort.glass";
/// Glass's dialog: its 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 {
use vectorcraft_effects::pixel::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, 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: form::Choices = if size.is_some() { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES };
let doc = vectorcraft_effects::effect_info(GLASS).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);
if let Some(v) = stored {
d.fields.insert(TEXTURE_IMAGE.into(), v);
}
let mut load = false;
ui.horizontal(|ui| {
let can_read = app.services.read.is_some();
load = ui.add_enabled_ui(can_read, |ui| widgets::flat_button(ui, tl!("Load Texture…"), 110.0)).inner.clicked();
if let Some((w, h)) = size {
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);
}
values_changed(d)
}
/// 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> {
use vectorcraft_engine::cmd::fileio;
let filters = fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, f.extensions)).collect();
let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?;
// The button is off where files can't be read.
let Some(read) = app.services.read.as_ref() else { return Ok(()) };
let stored = fileio::texture_image(&read(&path)?).map_err(|e| e.to_string())?;
d.fields.insert(vectorcraft_effects::pixel::TEXTURE_IMAGE.into(), json!(stored));
d.fields.insert("texture".into(), json!(vectorcraft_effects::pixel::LOADED));
Ok(())
}
/// The parameters of the effect dialogs that pick one of some values, as (label, value).
fn choices(effect: &str, key: &str) -> Option<form::Choices> {
match (effect, key) {
@@ -594,4 +645,39 @@ mod tests {
assert_eq!(values.join("|"), listed, "{effect} {key}");
}
}
/// Glass's Load Texture… reads the picked image into the dialog, the Texture menu shows Loaded
/// Texture and the texture's size instead of its stored text, and OK applies it.
#[test]
fn glass_load_texture_reads_the_picked_image_into_the_effect() {
let dir = std::env::temp_dir().join(format!("vectorcraft-glass-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": GLASS})).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 == "Frosted"), "{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 _ = std::fs::remove_dir_all(&dir);
}
}
+4
View File
@@ -4192,6 +4192,9 @@ effect Soft Меко
Blocks Блокове
Frosted Матирано
Tiny Lens Малка леща
Loaded Texture Заредена текстура
Load Texture… Зареждане на текстура…
Texture Image Изображение на текстурата
Rename Data Set Преименуване на набор от данни
Rename Variable Преименуване на променлива
Variable Options Опции за променливи
@@ -4762,6 +4765,7 @@ mark-style Japanese Японски
theme Appearance Външен вид
Load EPS into Clipboard Зареждане на EPS в клипборда
@msg an image of {w} × {h} pixels is too large to draw изображение с размери {w} × {h} пиксела е твърде голямо за рисуване
@msg the image can't be read as a texture изображението не може да бъде прочетено като текстура
Fill and Stroke Controls Управление на запълването и щриха
Coloring Controls Управление на оцветяването
Draw Mode Controls Управление на режима на рисуване
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2744,6 +2744,9 @@ effect Soft Měkký
Blocks Bloky
Frosted Matné sklo
Tiny Lens Drobné čočky
Loaded Texture Načtená textura
Load Texture… Načíst texturu…
Texture Image Obraz textury
Right Diagonal Pravá diagonála
Left Diagonal Levá diagonála
effect Horizontal Vodorovný
@@ -4839,6 +4842,7 @@ mark-style Japanese Japonské
@msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys}
Load EPS into Clipboard Načíst EPS do schránky
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
@msg the image can't be read as a texture obraz nelze načíst jako texturu
Fill and Stroke Controls Ovládací prvky výplně a tahu
Coloring Controls Ovládací prvky barev
Draw Mode Controls Ovládací prvky režimu kreslení
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4182,6 +4182,9 @@ theme Appearance Erscheinungsbild
Blocks Blöcke
Frosted Mattiert
Tiny Lens Kleine Linsen
Loaded Texture Geladene Struktur
Load Texture… Struktur laden…
Texture Image Strukturbild
Canvas Leinwand
Highlight Variable Variable hervorheben
New Variable Neue Variable
@@ -4366,6 +4369,7 @@ theme Appearance Erscheinungsbild
@msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys}
Load EPS into Clipboard EPS in die Zwischenablage laden
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
@msg the image can't be read as a texture das Bild kann nicht als Struktur gelesen werden
Basic Basis
Fill and Stroke Controls Steuerelemente für Fläche und Kontur
Coloring Controls Steuerelemente für Farbe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2034,6 +2034,9 @@ effect Soft Απαλό
Blocks Τμήματα
Frosted Παγωμένο
Tiny Lens Μικροσκοπικός φακός
Loaded Texture Φορτωμένη υφή
Load Texture… Φόρτωση υφής…
Texture Image Εικόνα υφής
Text Κείμενο
Visibility Ορατότητα
Activate Panel Ενεργοποίηση πάνελ
@@ -4880,3 +4883,4 @@ effect Points Σημεία
@msg the screen color sampler is already open η δειγματοληψία χρώματος από την οθόνη είναι ήδη ανοιχτή
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS αυτό είναι αρχείο PostScript (ένα .ai αποθηκευμένο σε παλαιότερη μορφή ή χωρίς συμβατότητα PDF, ή αρχείο EPS), όχι PDF: οι εντολές Αρχείο › Άνοιγμα και Αρχείο › Τοποθέτηση το διαβάζουν ως EPS
@msg this system has no screen color sampler αυτό το σύστημα δεν έχει δειγματοληψία χρώματος από την οθόνη
@msg the image can't be read as a texture η εικόνα δεν μπορεί να διαβαστεί ως υφή
Can't render this file because it contains an unexpected character in line 1857 and column 19.
+4
View File
@@ -4252,6 +4252,9 @@ axis Both Ambos
Blocks Bloques
Frosted Esmerilado
Tiny Lens Lente pequeña
Loaded Texture Textura cargada
Load Texture… Cargar textura…
Texture Image Imagen de textura
Rename Data Set Renombrar conjunto de datos
Rename Variable Renombrar variable
Variable Options Opciones de variable
@@ -4780,6 +4783,7 @@ mark-style Japanese Japonesas
@msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys}
Load EPS into Clipboard Cargar EPS en el portapapeles
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
@msg the image can't be read as a texture la imagen no se puede leer como textura
Fill and Stroke Controls Controles de relleno y trazo
Coloring Controls Controles de color
Draw Mode Controls Controles de modo de dibujo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2698,6 +2698,9 @@ effect Soft Pehmeä
Blocks Lohkot
Frosted Huurrettu
Tiny Lens Pieni linssi
Loaded Texture Ladattu tekstuuri
Load Texture… Lataa tekstuuri…
Texture Image Tekstuurikuva
Right Diagonal Oikea vinottainen
Left Diagonal Vasen vinottainen
effect Horizontal Vaaka
@@ -4892,3 +4895,4 @@ mark-style Japanese Japanilainen
@msg the screen color sampler is already open värin poiminta näytöltä on jo auki
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS tämä on PostScript-tiedosto (vanhemmassa muodossa tai ilman PDF-yhteensopivuutta tallennettu .ai tai EPS-tiedosto), ei PDF: Tiedosto › Avaa ja Tiedosto › Sijoita lukevat sen EPS-muodossa
@msg this system has no screen color sampler tässä järjestelmässä ei ole värin poimintaa näytöltä
@msg the image can't be read as a texture kuvaa ei voi lukea tekstuurina
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4264,6 +4264,9 @@ effect Soft Doux
Blocks Blocs
Frosted Dépoli
Tiny Lens Petites lentilles
Loaded Texture Texture chargée
Load Texture… Charger une texture…
Texture Image Image de texture
Rename Data Set Renommer le jeu de données
Rename Variable Renommer la variable
Variable Options Options de variable
@@ -4839,6 +4842,7 @@ mark-style Japanese Japonais
theme Appearance Apparence
Load EPS into Clipboard Charger un EPS dans le presse-papiers
@msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée
@msg the image can't be read as a texture l'image ne peut pas être lue comme texture
Fill and Stroke Controls Commandes de fond et de contour
Coloring Controls Commandes de couleur
Draw Mode Controls Commandes de mode de dessin
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -1991,6 +1991,8 @@
Load Profile… Profil betöltése…
Load SVG into Clipboard SVG betöltése a vágólapra
Load Text into Clipboard Szöveg betöltése a vágólapra
Load Texture… Textúra betöltése…
Loaded Texture Betöltött textúra
Local Libraries Helyi könyvtárak
Locate Object Objektum megkeresése
Location Hely
@@ -3650,6 +3652,7 @@
Text Wrap Options Körbefuttatás beállításai
Text Wrap Options… Körbefuttatás beállításai…
Texture Textúra
Texture Image Textúrakép
Texturizer Textúrázó
Texturizer… Textúrázó…
Thai Thai
@@ -4806,6 +4809,7 @@ imageTrace Outline Körvonal
@msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys}
Load EPS into Clipboard EPS betöltése a vágólapra
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
@msg the image can't be read as a texture a kép nem olvasható be textúraként
Fill and Stroke Controls Kitöltés és körvonal vezérlői
Coloring Controls Színezés vezérlői
Draw Mode Controls Rajzolási mód vezérlői
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4257,6 +4257,9 @@ effect Regular Normale
Blocks Blocchi
Frosted Smerigliato
Tiny Lens Lente piccola
Loaded Texture Texture caricata
Load Texture… Carica texture…
Texture Image Immagine texture
Rename Data Set Rinomina set di dati
Rename Variable Rinomina variabile
Variable Options Opzioni variabile
@@ -4411,6 +4414,7 @@ lighting Light Luce
@msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys}
Load EPS into Clipboard Carica EPS negli appunti
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
@msg the image can't be read as a texture l'immagine non può essere letta come texture
Basic Base
Fill and Stroke Controls Controlli di riempimento e traccia
Coloring Controls Controlli colore
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4251,6 +4251,9 @@ effect Vertical 垂直
Blocks ブロック
Frosted すりガラス
Tiny Lens 小さなレンズ
Loaded Texture 読み込んだテクスチャ
Load Texture… テクスチャを読み込み…
Texture Image テクスチャ画像
Rename Data Set データセットの名前を変更
Variable Options 変数のオプション
Edit Data Set データセットを編集
@@ -4405,6 +4408,7 @@ lighting Light ライト
@msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys}
Load EPS into Clipboard EPS をクリップボードに読み込む
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
@msg the image can't be read as a texture この画像はテクスチャとして読み込めません
Basic 基本
Fill and Stroke Controls 塗りと線のコントロール
Coloring Controls カラーのコントロール
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4229,6 +4229,9 @@ effect Vertical 세로
Blocks 블록
Frosted 서리 낀 유리
Tiny Lens 작은 렌즈
Loaded Texture 불러온 텍스처
Load Texture… 텍스처 불러오기…
Texture Image 텍스처 이미지
Rename Data Set 데이터 세트 이름 바꾸기
Variable Options 변수 옵션
Edit Data Set 데이터 세트 편집
@@ -4375,6 +4378,7 @@ lighting Light 광원
@msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys}
Load EPS into Clipboard EPS를 클립보드로 불러오기
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
@msg the image can't be read as a texture 이미지를 텍스처로 읽을 수 없습니다
Basic 기본
Fill and Stroke Controls 칠 및 획 컨트롤
Coloring Controls 색상 컨트롤
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2615,6 +2615,9 @@ effect Soft Miękkie
Blocks Bloki
Frosted Matowe szkło
Tiny Lens Mała soczewka
Loaded Texture Wczytana tekstura
Load Texture… Wczytaj teksturę…
Texture Image Obraz tekstury
Right Diagonal Przekątna w prawo
Left Diagonal Przekątna w lewo
effect Horizontal Poziomo
@@ -4022,6 +4025,7 @@ imageTrace Outline Kontur
@msg select an Image Trace object zaznacz obiekt obrysu obrazu
@msg could not decode image: {0} nie udało się zdekodować obrazu: {0}
@msg image is empty obraz jest pusty
@msg the image can't be read as a texture nie można odczytać obrazu jako tekstury
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) obraz ma {width} × {height} pikseli, więcej niż przyjmuje obrys obrazu ({max} megapikseli)
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset ten obrys utworzyłby ponad {max} punktów kontrolnych, więcej niż dokument może płynnie wyświetlić: zwiększ Szum, zmniejsz Ścieżki lub Kolory albo wybierz ustawienie o niższej wierności
@msg select an envelope zaznacz kopertę
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4328,6 +4328,9 @@ axis Both Ambos
Blocks Blocos
Frosted Fosco
Tiny Lens Lente Minúscula
Loaded Texture Textura carregada
Load Texture… Carregar textura…
Texture Image Imagem da textura
Variable Options Opções de Variável
Edit Data Set Editar Conjunto de Dados
Accented Edges Bordas Acentuadas
@@ -4845,3 +4848,4 @@ mark-style Japanese Japonesas
out from a highlighted edge to place its dimension | para fora a partir de uma borda destacada para posicionar a cota |
Color Panel Options Opções do Painel Cor
@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` deve ser grayscale, rgb, hsb, cmyk ou websafe
@msg the image can't be read as a texture a imagem não pode ser lida como textura
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4253,6 +4253,9 @@ axis Both Оба
Blocks Блоки
Frosted Матовое
Tiny Lens Мелкие линзы
Loaded Texture Загруженная текстура
Load Texture… Загрузить текстуру…
Texture Image Изображение текстуры
Rename Data Set Переименовать набор данных
Rename Variable Переименовать переменную
Variable Options Параметры переменной
@@ -4407,6 +4410,7 @@ lighting Light Свет
@msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys}
Load EPS into Clipboard Загрузить EPS в буфер обмена
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
@msg the image can't be read as a texture изображение не удаётся прочитать как текстуру
Basic Базовый
Fill and Stroke Controls Элементы управления заливкой и обводкой
Coloring Controls Элементы управления цветом
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4191,6 +4191,9 @@ effect Soft Mjuk
Blocks Block
Frosted Frostat
Tiny Lens Liten lins
Loaded Texture Inläst textur
Load Texture… Läs in textur…
Texture Image Texturbild
Rename Data Set Byt namn på datauppsättning
Rename Variable Byt namn på variabel
Variable Options Variabelalternativ
@@ -4885,3 +4888,4 @@ theme Appearance Utseende
@msg the screen color sampler is already open färghämtningen från skärmen är redan öppen
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS det här är en PostScript-fil (en .ai sparad i ett äldre format eller utan PDF-kompatibilitet, eller en EPS-fil), inte en PDF: Fil › Öppna och Fil › Placera läser den som EPS
@msg this system has no screen color sampler det här systemet saknar färghämtning från skärmen
@msg the image can't be read as a texture bilden kan inte läsas som en textur
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -4239,6 +4239,9 @@ axis Both Обидві
Blocks Блоки
Frosted Матове
Tiny Lens Дрібні лінзи
Loaded Texture Завантажена текстура
Load Texture… Завантажити текстуру…
Texture Image Зображення текстури
Rename Data Set Перейменувати набір даних
Rename Variable Перейменувати змінну
Variable Options Параметри змінної
@@ -4391,6 +4394,7 @@ lighting Light Світло
@msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys}
Load EPS into Clipboard Завантажити EPS у буфер обміну
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
@msg the image can't be read as a texture зображення не вдається прочитати як текстуру
Basic Базовий
Fill and Stroke Controls Елементи керування заливкою та обведенням
Coloring Controls Елементи керування кольором
Can't render this file because it contains an unexpected character in line 718 and column 40.
+4
View File
@@ -1801,6 +1801,8 @@
Load Profile… Tải hồ sơ…
Load SVG into Clipboard Tải SVG vào bảng tạm
Load Text into Clipboard Tải văn bản vào bảng tạm
Load Texture… Tải kết cấu…
Loaded Texture Kết cấu đã tải
Local Libraries Thư viện cục bộ
Locate Object Xác định vị trí đối tượng
Location Vị trí
@@ -3337,6 +3339,7 @@
Text Wrap Options Tùy chọn bao văn bản
Text Wrap Options… Tùy chọn bao văn bản…
Texture Kết cấu
Texture Image Ảnh kết cấu
Texturizer Texturizer
Texturizer… Texturizer…
Thai Tiếng Thái
@@ -4260,6 +4263,7 @@ theme Appearance Giao diện
@msg The graphics were lost and couldn't be restarted. Đồ họa đã mất và không thể khởi động lại.
@msg The graphics were lost and have been restarted Đồ họa đã mất và đã được khởi động lại
@msg the guide is on a hidden or locked layer đường căn nằm trên lớp ẩn hoặc bị khóa
@msg the image can't be read as a texture không thể đọc ảnh làm kết cấu
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) ảnh có kích thước {width} × {height} pixel, vượt quá giới hạn vector hóa ảnh ({max} megapixel)
@msg the image's pixels don't match its size pixel ảnh không khớp với kích thước
@msg the layer has no objects to clip by lớp không có đối tượng để cắt
Can't render this file because it contains an unexpected character in line 3729 and column 10.
+3
View File
@@ -3851,6 +3851,9 @@ effect Vertical 垂直
Blocks 块状
Frosted 磨砂
Tiny Lens 小镜头
Loaded Texture 已载入的纹理
Load Texture… 载入纹理…
Texture Image 纹理图像
Rename Data Set 重命名数据集
Rename Variable 重命名变量
Variable Options 变量选项
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3635,6 +3635,9 @@ effect Soft 柔和
Blocks 塊狀
Frosted 磨砂
Tiny Lens 小鏡頭
Loaded Texture 已載入的紋理
Load Texture… 載入紋理…
Texture Image 紋理影像
Rename Data Set 重新命名資料集
Rename Variable 重新命名變數
Variable Options 變數選項
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -776,6 +776,11 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
change = Some((key.clone(), json!(nx)));
}
}
// Glass's loaded texture shows its size; its dialog's Load Texture… replaces it.
Value::String(s) if key == vectorcraft_effects::pixel::TEXTURE_IMAGE => {
let size = vectorcraft_effects::pixel::texture(s).map(|t| t.size());
widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px")));
}
Value::String(s) => {
let mut buf = s.clone();
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(90.0));
@@ -72,6 +72,25 @@ fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() {
assert_eq!(effects(&app, None)[0].params["size"], json!(9));
}
/// The inline editor shows Glass's loaded texture by its size, not as its stored text.
#[test]
fn the_inline_editor_shows_a_loaded_texture_by_its_size() {
let mut app = app_with_rect();
let mut png = Vec::new();
image::GrayImage::from_fn(5, 3, |x, _| image::Luma([(x * 60) as u8]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let file = json!({"name": "t.png", "dataBase64": vectorcraft_format::base64_encode(&png)});
run(&mut app, "effect.apply", json!({"effect": "distort.glass", "params": {"textureImage": file}}));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
let stored = effects(&app, None)[0].params["textureImage"].as_str().unwrap().to_string();
assert!(texts.iter().any(|(t, _)| t == "5 × 3 px"), "{texts:?}");
assert!(!texts.iter().any(|(t, _)| t.contains(&stored[..24])), "the stored text isn't shown");
}
#[test]
fn free_distorts_inline_editor_opens_its_dialog_for_the_corners() {
let mut app = app_with_rect();
+1 -1
View File
@@ -41,7 +41,7 @@ its resolution, PDF export warns once per object with the requested and the lowe
| Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 1/15 (approximate Film Grain) | Colored Pencil, Cutout, Dry Brush, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor |
| Blur | Gaussian Blur, Radial Blur, Smart Blur | 3/3 | — |
| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 8/8 | — |
| Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | Glass's Load Texture |
| Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | — |
| Pixelate | Color Halftone, Crystallize, Mezzotint, Pointillize | 4/4 | — |
| 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 | 0/14 | all |
+1 -1
View File
@@ -202,7 +202,7 @@ 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
- **Missing:** 28 of 57 filters (the remaining 14 Artistic filters and all of Sketch), the Effect Gallery dialog, Load
Texture for Glass and Texturizer. Measured in
Texture for Texturizer. Measured in
[effects-parity.md](effects-parity.md).
- **Done:** the pixel-filter pipeline and 29 filters (Artistic: Film Grain, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Stylize,
Texture, Video), Brush Strokes on 2026-10-11, Film Grain on 2026-10-12, and Smart Blur's Edge Only and Overlay Edge
+16
View File
@@ -2275,6 +2275,22 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o
{"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}}
```
## Glass textures
Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or
`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD or PSB file, decoded within the pixel allowance the
renderer draws images in, counted as RGBA. `effect.apply` and `effect.setParams` store the image's gray levels in the effect as a grayscale PNG
in base64 and set `texture: "loaded"` unless the params give a `texture`. `effect.list` reports the stored string,
and a command given that string stores it unchanged. Any other string, a file that is not an image, or an image past
the allowance is an error. A save keeps the texture with the effect. Glass lays the texture one image pixel per point
at Scaling 100%, centered on the object's center and repeated in both directions. Each pixel's luma is the surface's
height, from 0 for black to 1 pt for white. A transparent pixel counts as middle gray, and a partly transparent pixel
is mixed with middle gray by its alpha.
```json
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"distort.glass","params":{"distortion":10,"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,
+3 -1
View File
@@ -320,7 +320,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, several lights, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
@@ -636,6 +636,8 @@ 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, PSD or PSB 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.
- **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536).
- **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness the shadow is the object's own colors with black added: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K), and in an RGB document each channel is multiplied by 1 − darkness. Darkness 0% gives the object's color; 100% gives black in an RGB document and keeps the colors under 100% black in a CMYK one. SVG export writes the object blurred, moved and darkened with `feComponentTransfer` (screen colors, in a CMYK document too), and SVG import reads it back; CSS export leaves the shadow out and marks the object unsupported. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434).