mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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:
@@ -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, ¶ms).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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user