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
+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();