Files
vectorcraft/crates/effects/src/params.rs
T
dotCooCoo e609b26a95 Warp: bend each style as one Bézier patch, and keep saved warps' look
Each of the fifteen Warp styles now bends the bounds as one Bézier patch.
The style sets the patch's control points from the bend and the size of
the bounds, the horizontal distortion scales each column of control
points about its middle, and the vertical distortion scales each row.
The Warp effects and warp envelopes share the map (`WarpNet`). Content
with no width or no height takes the middle of the patch along that
side.

A warp carries the map it draws with as `version`. New warps, the
built-in graphic styles' Warps and warps opened from .ai files get 1.
Warps in .vectorcraft documents saved before the version existed read
as 0 and keep the earlier map, so those documents keep their look.
Edits keep a warp's version, and Warp Options editing several objects
keeps each one's own. effect.apply, effect.setParams and the warp
envelope commands take `version` 0 or 1 (`1.0` counts as 1) and refuse
any other value. An .ai file holds a warp's settings and not its
version, so a version 0 warp saved to .ai opens again with version 1.
The .ai writer compares a style's settings without the version, so
saved warps still export as editable effects.

Docs: docs/mcp.md (Envelopes), docs/effects-parity.md (Warp),
docs/target-app-parity.md.
2026-11-11 19:40:01 -08:00

192 lines
8.6 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.
use super::catalog::{catalog_index, plugin_info};
use super::{CROP_MARKS, Effect, Map, Node, PIXEL_EFFECTS, RASTERIZE, Value, is_adjustment, is_pathfinder, is_three_d, json, util};
/// Dialog defaults for `id` (`None` for unknown effects).
pub fn default_params(id: &str) -> Option<Value> {
match catalog_index().get(id) {
Some(e) => Some(e.defaults.clone()),
None => plugin_info(id).map(|e| e.defaults),
}
}
/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see
/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and
/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`.
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
Some(Value::Object(m)) => m.clone(),
Some(_) => Map::new(),
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
};
// Upgraded before the defaults are under it: a default angle is no angle given.
if let Value::Object(p) = upgrade_params(id, params) {
out.extend(p);
}
Value::Object(out)
}
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's
/// map `version` given as a whole float is stored as an integer.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
let mut m = m.clone();
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
upgrade_warp_version(id, &mut m);
Value::Object(m)
}
other => other.clone(),
}
}
fn upgrade_light(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation"));
if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") {
return;
}
let degrees = |v: Option<Value>, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default);
let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0));
m.insert("lightRotation".into(), json!(rotation));
m.insert("lightHeight".into(), json!(height));
m.insert("lightBehind".into(), json!(behind));
}
fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let Some(strength) = m.remove("perspective") else { return };
if !m.contains_key("perspectiveAngle") {
let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0);
m.insert("perspectiveAngle".into(), json!(angle));
}
}
/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer.
fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) {
if !id.starts_with("warp.") {
return;
}
if let Some(v) = m.get_mut("version")
&& !v.is_u64()
&& let Some(n) = crate::warp::warp_version(v)
{
*v = json!(n);
}
}
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
if id != "stylize.scribble" {
return;
}
if let Some(variation) = m.remove("variation") {
for key in ["overlapVariation", "spacingVariation"] {
m.entry(key).or_insert_with(|| variation.clone());
}
}
if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) {
let wrapped = angle.rem_euclid(360.0);
m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped }));
}
}
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
let mut params = merged_params(id, params);
// A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry
// no version, so warps saved without one keep the earlier map.
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::warp::WARP_NET));
}
Some(Effect { id: id.to_string(), params, visible: true })
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
/// [`EffectInfo::lengths`]), and its sizes in raster pixels ([`crate::raster_px_range`]), which
/// keep their share of the object so. A distance left at its default is written out scaled; one
/// the effect doesn't have (Outline Stroke's weight following its stroke) stays absent.
pub fn scale_effect(e: &mut Effect, s: f64) {
let Some(info) = catalog_index().get(e.id.as_str()) else { return };
let merged = merged_params(&e.id, &e.params);
let relative = util::flag(&merged, "relative", false);
let mut keys: Vec<&str> = info.lengths.keys(relative).collect();
keys.extend(merged.as_object().into_iter().flat_map(|m| m.keys()).map(String::as_str).filter(|k| crate::raster_px_range(&e.id, k).is_some()));
for k in keys {
let v = util::num(&merged, k, f64::NAN);
if v.is_finite() {
if !e.params.is_object() {
e.params = Value::Object(Map::new());
}
e.params[k] = json!(v * s);
}
}
}
/// 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" | RASTERIZE)
|| PIXEL_EFFECTS.contains(&id)
|| id == crate::SVG_FILTER
}
/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead,
/// [`crop_marks_art`] and [`crate::addons::halftone::halftone_art`]; colour adjustments recolour,
/// [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
/// leaves the geometry as it is).
pub fn is_geometry(id: &str) -> bool {
!is_raster(id)
&& !is_pathfinder(id)
&& !is_adjustment(id)
&& id != CROP_MARKS
&& id != crate::addons::halftone::HALFTONE
&& !is_three_d(id)
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(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))
}
/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws
/// what the plug-in installed under its id draws ([`plugin_revision`]).
pub fn has_plugin_effect(n: &Node) -> bool {
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
}
/// Does `n` have a visible live-effect plug-in effect, its own or a fill's or stroke's, whose
/// plug-in paints the pieces it returns? What such an effect draws also depends on the document's
/// swatches and patterns, which its pieces may name.
pub fn has_painted_plugin_effect(n: &Node) -> bool {
let painted = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some_and(vectorcraft_plugins::effect::paints_output);
n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted))
}
/// Is a live-effect plug-in that paints the pieces it returns installed?
pub fn any_plugin_paints_output() -> bool {
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
}
/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred
/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]).
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32)
}