vectorcraft/crates/effects/src/lib.rs

327 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! DrawCraft live effects (Illustrator's Effect menu).
//!
//! Effects live in appearance stacks as [`Effect`] `{id, params, visible}` records. This crate
//! interprets them:
//!
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//!
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
//! noise (`seed` parameter, default 0).
#![forbid(unsafe_code)]
mod distort;
mod raster;
mod stylize;
mod util;
mod warp;
#[cfg(test)]
mod tests;
use drawcraft_doc::Effect;
use drawcraft_geom::{BezPath, PathData, Rect};
use serde_json::{Map, Value, json};
pub use raster::{RasterFx, outset, raster_effects};
pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInfo {
/// Stable id, e.g. `distort.roughen`.
pub id: &'static str,
/// Menu label (with the ellipsis when the effect has a dialog).
pub label: &'static str,
/// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`.
pub menu: &'static [&'static str],
/// Human/agent-readable parameter documentation.
pub params: &'static str,
/// Dialog defaults.
pub defaults: Value,
/// Raster (painted) rather than geometry effect.
pub raster: bool,
}
const WARP_DOC: &str =
"{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
const PATH: &[&str] = &["Effect", "Path"];
const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
pub const WARP_STYLES: [(&str, &str); 15] = [
("arc", "Arc…"),
("arcLower", "Arc Lower…"),
("arcUpper", "Arc Upper…"),
("arch", "Arch…"),
("bulge", "Bulge…"),
("shellLower", "Shell Lower…"),
("shellUpper", "Shell Upper…"),
("flag", "Flag…"),
("wave", "Wave…"),
("fish", "Fish…"),
("rise", "Rise…"),
("fisheye", "Fisheye…"),
("inflate", "Inflate…"),
("squeeze", "Squeeze…"),
("twist", "Twist…"),
];
/// Every supported effect with its dialog defaults.
pub fn effect_catalog() -> Vec<EffectInfo> {
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false };
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true };
let mut v = vec![
g(
"convertToShape.rectangle",
"Rectangle…",
SHAPE,
"{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}",
json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}),
),
g(
"convertToShape.roundedRectangle",
"Rounded Rectangle…",
SHAPE,
"{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}",
json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}),
),
g(
"convertToShape.ellipse",
"Ellipse…",
SHAPE,
"{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}",
json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}),
),
g(
"distort.freeDistort",
"Free Distort…",
DT,
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
),
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
g(
"distort.roughen",
"Roughen…",
DT,
"{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}),
),
g(
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0), moveV: pt (0), rotate: deg (0), copies: int (0), reflectX: bool, reflectY: bool}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false}),
),
g(
"distort.tweak",
"Tweak…",
DT,
"{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}",
json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}),
),
g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})),
g(
"distort.zigZag",
"Zig Zag…",
DT,
"{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}",
json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}),
),
g(
"path.offsetPath",
"Offset Path…",
PATH,
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
),
g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})),
g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})),
g(
"stylize.scribble",
"Scribble…",
STYLIZE,
"{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}),
),
r(
"stylize.dropShadow",
"Drop Shadow…",
STYLIZE,
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
),
r(
"stylize.innerGlow",
"Inner Glow…",
STYLIZE,
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}",
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}),
),
r(
"stylize.outerGlow",
"Outer Glow…",
STYLIZE,
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}",
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}),
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
];
for (suffix, label) in WARP_STYLES {
let id: &'static str = match suffix {
"arc" => "warp.arc",
"arcLower" => "warp.arcLower",
"arcUpper" => "warp.arcUpper",
"arch" => "warp.arch",
"bulge" => "warp.bulge",
"shellLower" => "warp.shellLower",
"shellUpper" => "warp.shellUpper",
"flag" => "warp.flag",
"wave" => "warp.wave",
"fish" => "warp.fish",
"rise" => "warp.rise",
"fisheye" => "warp.fisheye",
"inflate" => "warp.inflate",
"squeeze" => "warp.squeeze",
_ => "warp.twist",
};
v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
}
v
}
/// Catalogue entry for `id`.
pub fn effect_info(id: &str) -> Option<EffectInfo> {
effect_catalog().into_iter().find(|e| e.id == id)
}
/// Dialog defaults for `id` (`None` for unknown effects).
pub fn default_params(id: &str) -> Option<Value> {
effect_info(id).map(|e| e.defaults)
}
/// `params` layered over the defaults of `id` (unknown keys are kept).
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match default_params(id) {
Some(Value::Object(m)) => m,
_ => Map::new(),
};
if let Value::Object(p) = params {
for (k, v) in p {
out.insert(k.clone(), v.clone());
}
}
Value::Object(out)
}
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
effect_info(id)?;
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
}
/// Is `id` a raster (painted) effect?
pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
}
/// Does `id` change geometry?
pub fn is_geometry(id: &str) -> bool {
!is_raster(id) && effect_info(id).is_some()
}
/// Any visible geometry effect in the list?
pub fn has_geometry(effects: &[Effect]) -> bool {
effects.iter().any(|e| e.visible && is_geometry(&e.id))
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GeomContext {
/// Stroke weight used by `path.outlineStroke` when the effect has no `width`.
pub stroke_width: f64,
}
impl Default for GeomContext {
fn default() -> Self {
Self { stroke_width: 1.0 }
}
}
/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the
/// reference box of the first effect (usually the object's geometric bounds); each later effect
/// uses the bounds of its input. Raster and unknown effects are skipped.
pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData {
apply_geometry_with(effects, path, bounds, &GeomContext::default())
}
/// [`apply_geometry`] with an explicit [`GeomContext`].
pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData {
let mut cur = path.clone();
let mut b = bounds;
let mut first = true;
for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) {
if !first {
b = cur.bounds().unwrap_or(b);
}
first = false;
if cur.is_empty() {
break;
}
let p = merged_params(&e.id, &e.params);
cur = apply_one(&e.id, &p, &cur, b, ctx);
}
cur
}
/// Convenience wrapper on kurbo paths.
pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath {
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
}
fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
"distort.roughen" => distort::roughen(path, b, p),
"distort.transform" => distort::transform(path, b, p),
"distort.tweak" => distort::tweak(path, b, p),
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)),
"distort.zigZag" => distort::zig_zag(path, b, p),
"path.offsetPath" => {
let off = num(p, "offset", 10.0).clamp(-1e5, 1e5);
if off.abs() < 1e-9 {
return path.clone();
}
drawcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
}
"path.outlineStroke" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width);
if w <= 0.0 {
return path.clone();
}
drawcraft_pathops::outline_stroke(path, w, drawcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0))
}
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
"stylize.scribble" => stylize::scribble(path, b, p),
_ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) {
Some(style) => warp::warp(path, b, style, p),
None => path.clone(),
},
}
}