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;