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.
This commit is contained in:
dotCooCoo
2026-11-11 19:40:01 -08:00
parent 765bd30783
commit e609b26a95
39 changed files with 906 additions and 62 deletions
+2 -2
View File
@@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: false,
options: Default::default(),
@@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() {
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: true,
options: Default::default(),
+196 -2
View File
@@ -65,7 +65,7 @@ pub use expand::{
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version};
#[cfg(test)]
mod tests {
@@ -132,6 +132,200 @@ mod tests {
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
/// Each warp style's Bézier net, at points worked out by a separate implementation of the same
/// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) →
/// the point in the box (origin at its top left, y down).
#[test]
fn warp_nets_bend_each_style_as_its_own_patch() {
#[rustfmt::skip]
let samples = [
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045),
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443),
("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666),
("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371),
("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712),
("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703),
("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585),
("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631),
("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44),
("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792),
("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352),
("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48),
("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472),
("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68),
("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68),
("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224),
("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769),
];
for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples {
let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") };
let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v);
assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})");
}
}
/// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half
/// circle through the bottom corners about the bottom's middle, and the top edge the one 100
/// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle.
#[test]
fn warp_net_arc_edges_are_concentric_circles() {
let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false);
let center = Point::new(100.0, 100.0);
for i in 0..=20 {
let u = f64::from(i) / 20.0;
for (v, r) in [(0.0, 200.0), (1.0, 100.0)] {
let d = net.at(u, v).distance(center);
assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}");
// Its ends and middle lie on the circle.
if i % 10 == 0 {
assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}");
}
}
}
// Bend 0 leaves the box as it is.
for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] {
let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false);
assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}");
}
}
/// The horizontal distortion scales each column of the net about its middle (narrowing the left
/// side for a positive value), then the vertical one each row (narrowing the top).
#[test]
fn warp_net_distortion_scales_the_columns_then_the_rows() {
let corners = |dh: f64, dv: f64| {
let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false);
[net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)]
};
let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9);
assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0));
assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5));
// Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and
// it then scales by 1.4 about its middle, (100, 0).
assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4));
}
/// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number
/// counts as 0, and out of range as its limit; a size that isn't a number as 0.
#[test]
fn warp_net_takes_any_numbers() {
for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] {
for (b, dh, w, h) in
[(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)]
{
for vertical in [false, true] {
let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6);
assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}");
}
}
}
let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false);
assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false));
}
/// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only
/// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's
/// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number,
/// gives finite points inside the box and past it, and the unit square's points scaled up where
/// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite
/// width and a height of 1e-300 or 100 every y is the unit square's times the height, also
/// where x goes past the largest finite number.
#[test]
fn warp_net_bends_a_huge_box_without_overflowing() {
let w = 1e308;
let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false);
let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false);
for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] {
let (p, q) = (flag.at(u, v), small.at(u, v));
assert!(p.is_finite(), "({u}, {v}): {p:?}");
assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}");
assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}");
}
let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false);
assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300));
let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false);
for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] {
let p = fisheye.at(u, v);
assert!(p.is_finite(), "({u}, {v}): {p:?}");
}
let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false);
let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE);
let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1));
assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}");
// The bottom of a box with a subnormal height stays exactly at that height: the net is
// built on the box halved, and the height's lowest bit but one is set.
let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2);
let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0);
assert_eq!(sub.y, low, "{sub:?}");
for h in [1e-300, 100.0] {
let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false);
for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] {
let (p, q) = (tall.at(u, v), unit.at(u, v));
assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}");
}
}
}
/// Content with no width or no height takes the middle of a warp envelope's patch along that
/// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight.
#[test]
fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() {
let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let arc = warp("arc");
let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0));
for y in [0.0, 30.0, 100.0] {
let p = map(Point::new(100.0, y));
assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}");
}
let bulge = warp("bulge");
let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0));
for x in [0.0, 70.0, 200.0] {
let p = map(Point::new(x, 50.0));
assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}");
}
}
/// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written
/// as none, so files keep their bytes, and the nets' version is written.
#[test]
fn warp_envelopes_write_their_map_version_only_when_it_has_one() {
let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap();
assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}");
assert!(!serde_json::to_string(&old).unwrap().contains("version"));
let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let text = serde_json::to_string(&new).unwrap();
assert!(text.contains(r#""version":1"#), "{text}");
assert_eq!(serde_json::from_str::<EnvelopeKind>(&text).unwrap(), new);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
@@ -537,7 +731,7 @@ mod tests {
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = [Arc::new(rect)];
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
+8
View File
@@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -19,6 +23,10 @@ pub enum EnvelopeKind {
v: f64,
#[serde(default = "yes")]
horizontal: bool,
/// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net,
/// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point).
#[serde(default, skip_serializing_if = "is_zero")]
version: u32,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh {
+11 -2
View File
@@ -1,5 +1,5 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point};
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
@@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64)
pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> {
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
match kind {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => {
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
let b = bend.clamp(-100.0, 100.0) / 100.0;
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
if uses_net(*version) {
let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal);
// Content with no width or no height takes the middle of the patch along that side.
let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0);
return Box::new(move |u: f64, v: f64| {
let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v });
frame * (src.origin() + net.at(u, v).to_vec2())
});
}
let c = src.center();
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
let horizontal = *horizontal;
+331
View File
@@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6
(x2, y2)
}
// =====================================================================================
// Warp nets: each style as one Bézier patch over its box
// =====================================================================================
/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With
/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep
/// their look; new and imported warps are given this one.
pub const WARP_NET: u32 = 1;
/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws
/// with the newest map it has.
pub fn uses_net(version: u32) -> bool {
version >= WARP_NET
}
/// The map version `v` names when it is one this build draws: a whole number from 0 to
/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`).
pub fn warp_version(v: &serde_json::Value) -> Option<u32> {
let n = v.as_f64()?;
(n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32)
}
/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row
/// from the top, in the box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug)]
struct Net {
rows: usize,
cols: usize,
p: [[Point; 4]; 4],
}
impl Net {
/// The undistorted box `w` × `h` as a `rows` × `cols` net.
fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self {
let mut p = [[Point::ZERO; 4]; 4];
for (r, row) in p.iter_mut().enumerate().take(rows) {
for (c, q) in row.iter_mut().enumerate().take(cols) {
*q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64));
}
}
Self { rows, cols, p }
}
/// Upside down within a box `h` high: the rows in reverse order, each point mirrored.
fn mirrored(self, h: f64) -> Self {
let mut out = self;
for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) {
for (q, f) in to.iter_mut().zip(from).take(self.cols) {
*q = Point::new(f.x, h - f.y);
}
}
out
}
/// Rows and columns exchanged, and x with y: a net made on the box turned on its side.
fn transposed(self) -> Self {
let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] };
for (r, row) in self.p[..self.rows].iter().enumerate() {
for (c, q) in row[..self.cols].iter().enumerate() {
out.p[c][r] = Point::new(q.y, q.x);
}
}
out
}
/// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from
/// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1).
fn distort_columns(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for c in 0..cols {
let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0);
let mid = half_way(self.p[0][c], self.p[rows - 1][c]);
for row in &mut self.p[..rows] {
row[c] = mid + (row[c] - mid) * s;
}
}
}
/// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0
/// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1).
fn distort_rows(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for (r, row) in self.p[..rows].iter_mut().enumerate() {
let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0);
let mid = half_way(row[0], row[cols - 1]);
for q in &mut row[..cols] {
*q = mid + (*q - mid) * s;
}
}
}
/// The same patch as a bicubic net (degree elevation, which leaves the surface as it is).
fn bicubic(self) -> [[Point; 4]; 4] {
let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect();
let mut out = [[Point::ZERO; 4]; 4];
for c in 0..4 {
let col: Vec<Point> = rows.iter().map(|row| row[c]).collect();
for (to, q) in out.iter_mut().zip(cubic_line(&col)) {
to[c] = q;
}
}
out
}
}
/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one.
fn half_way(a: Point, b: Point) -> Point {
Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y)
}
/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form.
fn cubic_line(p: &[Point]) -> [Point; 4] {
match *p {
[a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b],
[a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c],
[a, b, c, d] => [a, b, c, d],
_ => [Point::ZERO; 4],
}
}
/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord`
/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along
/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is
/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord.
fn arc_handles(chord: f64, b: f64) -> (f64, f64) {
let th = b.abs().min(1.0) * PI;
let len = chord / (3.0 * (th / 4.0).cos().powi(2));
(len * (th / 2.0).cos(), len * (th / 2.0).sin())
}
/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive
/// `b` when `up`, down when not, the other way for a negative one.
fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] {
let (ax, ay) = arc_handles(w, b);
let dy = if (b > 0.0) == up { -ay } else { ay };
[Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)]
}
/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top
/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly
/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean
/// out by θ/2.
fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] {
let th = b.abs().min(1.0) * PI;
let (s, c) = (th / 2.0).sin_cos();
let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c)));
let (ax, ay) = arc_handles(tr.x - tl.x, b);
let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr];
let bottom = arc_row(w, h, b.abs(), true);
let mut out = [[Point::ZERO; 4]; 4];
for (r, row) in out.iter_mut().enumerate() {
for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) {
*q = t.lerp(u, r as f64 / 3.0);
}
}
out
}
/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion.
/// `vertical`: the net of the box turned on its side, where Rise moves the other half.
fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net {
use WarpStyle::*;
let two = |top: [Point; 4], bottom: [Point; 4]| {
let mut p = [[Point::ZERO; 4]; 4];
(p[0], p[1]) = (top, bottom);
Net { rows: 2, cols: 4, p }
};
let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y));
match style {
Arc => {
let rows = arc_rows(w, h, b);
let net = two(rows[0], rows[3]);
if b < 0.0 { net.mirrored(h) } else { net }
}
ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)),
ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)),
Arch => {
let top = arc_row(w, 0.0, b, true);
two(top, top.map(|q| Point::new(q.x, q.y + h)))
}
Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)),
ShellLower | ShellUpper => {
let a = arc_rows(w, h, b);
let mut net = Net::flat(w, h, 4, 4);
if b >= 0.0 {
// The outer arc and the ends of the row inside it, upside down for Shell Lower.
net.p[0] = a[0];
(net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]);
if style == ShellLower { net.mirrored(h) } else { net }
} else {
// The inner arc and the ends of the row outside it, upside down for Shell Upper.
net.p[3] = a[3];
(net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]);
if style == ShellUpper { net.mirrored(h) } else { net }
}
}
Flag | Fish | Rise => {
let mut net = Net::flat(w, h, 2, 4);
let d = 2.0 * b * h;
match style {
Flag => {
for row in &mut net.p[..2] {
(row[1].y, row[2].y) = (row[1].y + d, row[2].y - d);
}
}
Fish => {
(net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d);
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
}
_ => {
for row in &mut net.p[..2] {
if vertical {
(row[2].y, row[3].y) = (row[2].y - d, row[3].y - d);
} else {
(row[0].y, row[1].y) = (row[0].y + d, row[1].y + d);
}
}
}
}
net
}
Wave => {
let mut net = Net::flat(w, h, 3, 4);
let d = 2.0 * b * h;
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
net
}
Fisheye => {
let mut net = Net::flat(w, h, 4, 4);
let c = Point::new(w / 2.0, h / 2.0);
for row in &mut net.p[1..3] {
for q in &mut row[1..3] {
*q = c + (*q - c) * (1.0 + 4.0 * b);
}
}
net
}
Inflate | Squeeze => {
let mut net = Net::flat(w, h, 3, 3);
let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 };
net.p[0][1].y -= b * h / 2.0;
net.p[2][1].y += b * h / 2.0;
net.p[1][0].x -= side;
net.p[1][2].x += side;
net
}
Twist => {
// The four inner points move round: clockwise for a positive bend.
let mut net = Net::flat(w, h, 4, 4);
let (dx, dy) = (b.abs() * w, b.abs() * h);
if b >= 0.0 {
net.p[1][1].x += dx;
net.p[1][2].y += dy;
net.p[2][2].x -= dx;
net.p[2][1].y -= dy;
} else {
net.p[1][1].y += dy;
net.p[1][2].x -= dx;
net.p[2][2].y -= dy;
net.p[2][1].x += dx;
}
net
}
}
}
/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns
/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the
/// box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WarpNet {
/// The control points over the box divided by `scale`.
net: [[Point; 4]; 4],
/// 1, or a power of two when the net over the box itself does not fit in finite numbers.
scale: f64,
w: f64,
h: f64,
}
impl WarpNet {
/// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each
/// -1..1, clamped); `vertical` bends along the box's height.
pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self {
let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 };
let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 };
let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h));
let build = |w: f64, h: f64| {
let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) };
net.distort_columns(dh);
net.distort_rows(dv);
net.bicubic()
};
// A box near the largest finite size can overflow the net. The net is then built on the box
// divided by the smallest power of two that fits (2 to 4096), an exact division for every
// side above the subnormal range, and points are multiplied back as they are read.
let mut out = Self { net: build(w, h), scale: 1.0, w, h };
while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) {
let scale = out.scale * 2.0;
out = Self { net: build(w / scale, h / scale), scale, w, h };
}
out
}
/// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A
/// coordinate the patch would send past the largest finite number is the box's own, held to
/// the finite range.
pub fn at(&self, u: f64, v: f64) -> Point {
let p = self.at_scaled(u, v);
let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) };
let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) };
Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h))
}
/// [`Self::at`] divided by the net's scale.
fn at_scaled(&self, u: f64, v: f64) -> Point {
let bern = |t: f64| {
let s = 1.0 - t;
[s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t]
};
let (bu, bv) = (bern(u), bern(v));
let (mut x, mut y) = (0.0, 0.0);
for (row, wv) in self.net.iter().zip(bv) {
for (q, wu) in row.iter().zip(bu) {
x += wv * wu * q.x;
y += wv * wu * q.y;
}
}
Point::new(x, y)
}
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
+26 -2
View File
@@ -192,8 +192,14 @@ fn round(h: &str, width: f64) -> StrokeLayer {
StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) }
}
/// Live effect `id` with `params` (the others keep the effect's defaults).
fn fx(id: &str, params: Value) -> Effect {
/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its
/// style's Bézier net, as a new Warp does.
fn fx(id: &str, mut params: Value) -> Effect {
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::live::WARP_NET));
}
Effect { id: id.into(), params, visible: true }
}
@@ -552,6 +558,24 @@ mod tests {
assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]);
}
/// The built-in styles' Warps draw with the map a new Warp takes.
#[test]
fn builtin_warp_styles_take_the_nets_map() {
let mut warps = 0;
for b in STYLE_LIBRARIES {
let lib = builtin_style_library(b.id).unwrap();
for g in &lib.styles {
let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect();
effects.extend(g.appearance.items.iter().flat_map(|i| i.effects()));
for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) {
assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id);
warps += 1;
}
}
}
assert!(warps >= 3, "{warps}");
}
#[test]
fn vcstyles_round_trip() {
let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone();
+1 -1
View File
@@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
+1 -1
View File
@@ -94,7 +94,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
pub use trap::TRAP;
pub use vectorcraft_three_d::Mesh;
pub use warp::{WarpStyle, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version};
mod catalog;
mod geometry;
+25 -2
View File
@@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// (`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°).
/// 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) => {
@@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value {
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(),
@@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
}
}
/// 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;
@@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
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
+51
View File
@@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() {
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
}
/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as
/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the
/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map
/// moves it 0.6 × 25 = 15.
#[test]
fn a_warp_draws_with_the_map_its_version_names() {
let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0));
// u(1 - u)(1 - 2u) peaks at √3 / 18.
let peak = 360.0 * 3f64.sqrt() / 18.0;
for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] {
let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap();
assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}");
assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}");
}
}
/// A straight line has a box with no width or no height, and the net bends it as the middle of
/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye,
/// Inflate and Squeeze a horizontal line stays straight.
#[test]
fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() {
let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>();
let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0));
let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical);
assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}");
let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0));
for style in ["bulge", "fisheye", "inflate", "squeeze"] {
let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal);
assert!(
anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)),
"{style}: {out:?}"
);
}
}
/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none,
/// so a saved warp's params don't gain one.
#[test]
fn new_warps_take_the_nets_version() {
for (s, _) in WARP_STYLES {
let id = format!("warp.{s}");
let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}");
let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(0), "{id}");
assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}");
}
let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") };
assert!(e.params.get("version").is_none());
}
#[test]
fn transform_copies_and_move() {
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
+27 -8
View File
@@ -1,14 +1,22 @@
//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion.
//!
//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
//! split into short pieces whose control points are mapped (see `map_nonlinear`).
//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]);
//! one without (saved before the version existed) is a map on normalized box coordinates
//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control
//! points are mapped (see `map_nonlinear`).
use serde_json::Value;
use vectorcraft_geom::{PathData, Point, Rect};
use crate::util::{map_nonlinear_within, num, text};
pub use vectorcraft_doc::live::{WarpStyle, warp_point};
pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version};
/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent.
pub fn map_version(p: &Value) -> u32 {
let v = num(p, "version", 0.0);
if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 }
}
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
@@ -16,6 +24,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
let piece = b.width().hypot(b.height()) / 24.0;
if uses_net(map_version(p)) {
let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical);
let (w, h) = (b.width(), b.height());
// A box with no width or no height takes the middle of the patch along that side.
let f = |q: Point| {
let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 });
b.origin() + net.at(u, v).to_vec2()
};
return map_nonlinear_within(path, piece, max, f);
}
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
@@ -29,17 +48,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
/// tests both sides of it).
/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as
/// none: the threshold must match the one in [`warp_point`]'s Arc case
/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any
/// bend moves the outline.
pub(crate) fn bends(id: &str, p: &Value) -> bool {
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
let bend = share("bend", 50.0);
let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 };
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
}
+4 -4
View File
@@ -136,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make with Warp…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"),
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
has_selection,
env_make_warp
),
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content",
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
@@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
has_doc,
env_info
),
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Options…",
["Object", "Envelope Distort"],
None,
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
+11 -6
View File
@@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
check_numbers(p, WARP_NUMBERS, cmd)?;
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
// A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version`
// names one.
let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur {
(style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0);
}
if let Some(n) = p.get("version") {
version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?;
}
if let Some(s) = text_param(p, "style", cmd)? {
let s = s.strip_prefix("warp.").unwrap_or(s);
@@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
Some(o) => o == "horizontal",
None => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version })
}
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
@@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match &**kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
+8
View File
@@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
}
// A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
let newest = vectorcraft_render::effects::WARP_NET;
if effect.starts_with("warp.")
&& let Some(v) = given.get("version")
&& vectorcraft_render::effects::warp_version(v).is_none()
{
return Err(bad(id, format!("`version` must be 0 or {newest}")));
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
+34
View File
@@ -147,3 +147,37 @@ fn show_all_hidden_attributes() {
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.has_hidden());
}
/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit
/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and
/// a map this build doesn't draw is refused.
#[test]
fn warps_keep_the_map_their_version_names() {
let (mut s, id) = session_with_rect();
let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned();
run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}}));
assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"}));
assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
// As a file saved before the version existed gives it.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
p.remove("version");
}
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}}));
assert_eq!(version(&s), None);
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}}));
assert_eq!(version(&s), Some(json!(1)));
run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}}));
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
// A whole number given as a float is that version.
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}}));
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1));
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}}));
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
for bad in [json!(2), json!(-1), json!(0.5), json!("1")] {
assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}");
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}");
}
assert_eq!(node(&s, id).appearance.effects.len(), 2);
}
+47 -2
View File
@@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err());
}
/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps
/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its
/// warp or its options change.
#[test]
fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() {
// The surface point a quarter of the way along the top edge.
let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p;
let at = |x, y| vectorcraft_geom::Point::new(x, y);
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40}));
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET));
// Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4.
assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e));
// As a file saved before the version existed gives it.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind)
&& let EnvelopeKind::Warp { version, .. } = &mut **kind
{
*version = 0;
}
s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap();
s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
// The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights.
assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e));
// `version` moves a warp to a map, and a new warp can take the earlier one.
s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0}));
assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old));
for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] {
for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] {
let r = s.execute(cmd, &json!({"version": bad}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}");
}
}
}
#[test]
fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let mut s = session();
@@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let content = node(&s, a);
assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
// The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
// The mesh follows the arc: its top corners swing out past the content's sides and up.
let b = m.outline().bounds().unwrap();
assert!(b.y1 > 201.0, "{b:?}");
assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}");
assert_eq!(s.doc().unwrap().selection.objects, ids);
}
+4 -3
View File
@@ -286,17 +286,18 @@ fn symbols_bend_with_the_envelope() {
let e = arch(&mut s, 60.0, json!({}));
let pts = anchors(&evaluated(&s, e));
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The
// arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it.
let mut r = vectorcraft_render::Renderer::new();
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60);
s.edit("edit symbol", |d, _| {
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
d.symbols[0].art = Arc::new(art);
Ok(())
})
.unwrap();
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200);
}
fn strokes(nodes: &[Node]) -> usize {
+2 -2
View File
@@ -483,8 +483,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() {
let pts = all_anchors(&out);
assert!(pts.len() > 4, "curves are subdivided");
assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}");
// It really is bent: the sides moved down.
assert!(pts.iter().any(|p| p.y > 201.0));
// It really is bent: the top corners swung out past the sides.
assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0));
}
#[test]
+18
View File
@@ -6627,6 +6627,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
for id in &ids {
cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"})));
cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1})));
}
for (id, params) in cases {
let effect = fx(id, params.clone());
@@ -6643,6 +6644,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
let got = &back.graphic_styles[0].appearance.effects;
assert_eq!(got.len(), 1, "{id} {params}");
assert_eq!(got[0].id, effect.id);
// A warp read from a file bends as its style's Bézier net.
if id.starts_with("warp.") {
assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}");
}
let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params));
for (key, value) in w.as_object().unwrap() {
// Random isn't written, and a style with it isn't read; its seed is the default.
@@ -6663,6 +6668,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
}
}
}
// A warp saved before the map version existed is written with its settings too, and reads back on
// the nets' map.
let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true };
let mut doc = Document::new(200.0, 100.0);
let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) });
g.id = 3;
*doc.graphic_styles = vec![g];
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
let back = read_back(&doc);
let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) };
assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0)));
assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET));
// A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians.
let mut doc = Document::new(200.0, 100.0);
let effects = vec![
+8 -1
View File
@@ -889,9 +889,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) ->
}
/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as
/// `#rrggbb`.
/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader
/// gives every warp it reads the newest map, so a warp saved before the version existed is written
/// with its settings as before.
fn normalized_params(e: &Effect) -> Value {
let mut p = vectorcraft_effects::merged_params(&e.id, &e.params);
if e.id.starts_with("warp.")
&& let Value::Object(m) = &mut p
{
m.remove("version");
}
if let Some(m) = word(&p, "mode") {
p["mode"] = Value::String(m);
}
+1
View File
@@ -244,6 +244,7 @@ fn warp(o: &Obj) -> Option<EnvelopeKind> {
h: percent("DeformHoriz")?,
v: percent("DeformVert")?,
horizontal: !vertical,
version: vectorcraft_doc::live::WARP_NET,
})
}
+1 -1
View File
@@ -1259,7 +1259,7 @@ mod tests {
fn envelopes_in_symbol_definitions_spend_the_imports_checks() {
use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys};
use vectorcraft_doc::live::EnvelopeKind;
let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET };
let envelope =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want));
let mut reader = Reader::new(b"");
+1
View File
@@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
"horizontal": pct("DeformHoriz"),
"vertical": pct("DeformVert"),
"orientation": if flag("Rotate") { "vertical" } else { "horizontal" },
"version": vectorcraft_doc::live::WARP_NET,
}),
)
}
+2
View File
@@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() {
// The warp is the whole object's; the characters paint below every item.
let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) };
assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal")));
// A file's warp bends as its style's Bézier net.
assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET));
assert_eq!(st.contents_index, None);
}
+8 -6
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use kurbo::{Affine, Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::live::WARP_NET;
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use super::*;
@@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() {
for (style, bend, h, v, rotate, id) in
[(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")]
{
let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 };
let want =
EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET };
let body =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, fidelity, editing, options) = live_envelope(&a[0]);
let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") };
assert_eq!((s.as_str(), *horizontal), (id, rotate == 0));
let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") };
assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET));
assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}");
assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW));
assert_eq!(content.len(), 1, "{content:#?}");
@@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() {
"twist",
];
for (n, id) in ids.iter().enumerate() {
let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0);
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want))));
let a = art(&r.doc);
@@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() {
#[test]
fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
@@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
const WHITE: &str = "0 0 0 0 1 1 1 Xa\n";
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let content = envelope_content().replace(RED, WHITE);
let drawn = drawn_content(&other).replace(RED, WHITE);
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn);
+8 -1
View File
@@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String {
fn arc_envelope(text: &Node, bend: f64) -> Node {
let mut text = text.clone();
text.visible = true;
let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::live::EnvelopeKind::Warp {
style: "arc".into(),
bend,
h: 0.0,
v: 0.0,
horizontal: true,
version: vectorcraft_doc::live::WARP_NET,
};
Node::new(
vectorcraft_doc::NodeId(1_000_000),
NodeKind::Envelope {
+2 -2
View File
@@ -781,7 +781,7 @@ mod tests {
d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) });
}
let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY });
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(first)],
kind: Box::new(kind),
@@ -815,7 +815,7 @@ mod tests {
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() });
}
let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY })));
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: content.to_vec(),
kind: Box::new(kind),
+3 -3
View File
@@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() {
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) });
let l = d.layers[0].id;
for (i, content) in [typed, shown].into_iter().enumerate() {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
kind: Box::new(kind),
@@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style))));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(typed)],
kind: Box::new(kind),
@@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() {
const FAMILY: &str = "Mapped Serif 4";
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style);
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = Node::new(
vectorcraft_doc::NodeId(1),
NodeKind::Envelope {
+2 -2
View File
@@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() {
d.patterns.push(def);
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0));
let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() };
let envelope = Node::new(
d.alloc_id(),
@@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() {
/// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort
/// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it.
fn arc_envelope(d: &mut Document, content: Vec<Node>, patterns: bool) -> Node {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() };
let content = content.into_iter().map(Arc::new).collect();
Node::new(
+1 -1
View File
@@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] {
/// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no
/// bend), with Distort Appearance on or off.
fn envelope_doc(content: Node, distort_appearance: bool) -> Document {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
+1 -1
View File
@@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() {
let placed = d.remove(NodeId(50)).unwrap();
let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") };
let p = (**p).clone();
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![placed],
kind: Box::new(kind),
+2 -2
View File
@@ -1543,7 +1543,7 @@ mod tests {
let d = doc_with_live_over_green(|d| {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0);
let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a);
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = vec![std::sync::Arc::new(red)];
let n = Node::new(
d.alloc_id(),
@@ -1574,7 +1574,7 @@ mod tests {
use vectorcraft_geom::{Affine, Rect};
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = vec![std::sync::Arc::new(red)];
let options = Default::default();
Node::new(
+7
View File
@@ -879,6 +879,13 @@ fn command(d: &Dialog) -> (&'static str, Value) {
p["item"] = item.unwrap_or(Value::Null);
p["params"] = params;
let id = d.str("__effect");
// Each target keeps the map its own Warp draws with: the dialog's `version` is the first
// target's.
if super::warp_effect::is_warp(&id)
&& let Some(Value::Object(m)) = p.get_mut("params")
{
m.remove("version");
}
// The displayed style comes from the first target. Preserve other targets' Warp IDs
// unless the user deliberately chooses Style, including after a preview rollback.
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
+1 -1
View File
@@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?;
let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None };
let bar = match &**kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => {
Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal }
}
EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols },
+30
View File
@@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
assert_eq!(app.session.doc().unwrap().doc, moved);
}
}
/// Warp Options editing the Warps of two objects, one new and one saved without a map version,
/// changes the Bend of both and keeps each one's own `version`.
#[test]
fn warp_options_edit_keeps_each_targets_map_version() {
let (mut app, first) = app();
app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap();
let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap();
// As a file saved before the version existed gives it.
let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc);
if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
p.remove("version");
}
app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap();
app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS);
let ctx = context();
settle(&mut app, &ctx);
click_text(&mut app, &ctx, "Edit");
settle(&mut app, &ctx);
make_warp_edit(&mut app, &ctx, "bend");
click_text(&mut app, &ctx, "OK");
assert!(app.ui.dialog.is_none());
let doc = app.session.doc().unwrap().doc.clone();
let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone();
assert_eq!((&params(first)["bend"], &params(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET)));
assert_eq!(params(second)["bend"], json!(25.0));
assert!(params(second).get("version").is_none(), "{:?}", params(second));
}
+5 -3
View File
@@ -1,7 +1,7 @@
//! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema.
use egui::{Pos2, Rect, Stroke, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point};
use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle};
use super::form;
use crate::state::Dialog;
@@ -38,6 +38,8 @@ fn shown(label: &str) -> String {
fn style_preview(ui: &mut egui::Ui, suffix: &str) {
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover());
let Some(style) = WarpStyle::from_id(suffix) else { return };
// The map new warps take, on a box 0.55 times as high as it is wide.
let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false);
let mut lines = vec![];
let mut bounds = Rect::NOTHING;
for vertical in [false, true] {
@@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) {
.map(|i| {
let along = -1.0 + f64::from(i) / 8.0;
let (x, y) = if vertical { (fixed, along) } else { (along, fixed) };
let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y);
let p = Pos2::new(x as f32, y as f32);
let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0);
let p = Pos2::new(q.x as f32, q.y as f32);
bounds.extend_with(p);
p
})
+1 -1
View File
@@ -28,7 +28,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
| Rasterize | Rasterize… (as a live effect) | 1/1 | — |
| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets |
| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `<filter>` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning |
| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) |
| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) |
Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers
its resolution, PDF export warns once per object with the requested and the lowest achieved ppi
+14
View File
@@ -8238,6 +8238,20 @@ share of the content's width or height in proportion to the length of its contro
its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file
keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or
reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them.
A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and
the size of the bounds (Arc's top and bottom edges, for example, are cubic curves close to arcs around one center), then
the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales
each row. Content with no width or no height takes the middle of the patch along that side. New warp envelopes, and
those opened from `.ai` files, carry `version` 1 for this map, and `object.envelope.info` reports it. Envelopes in
`.vectorcraft` documents saved before the version existed read as `version` 0, the earlier map, and keep their look,
and editing an envelope keeps its version. `version` (0 or 1) on `object.envelope.makeWithWarp`,
`object.envelope.resetWithWarp` and `object.envelope.options` sets the map. The Warp effects (`warp.arc` …
`warp.twist`) take the same map through the same `version` param: `effect.apply` gives a new Warp `version` 1 unless
`params.version` is 0, the built-in graphic styles' Warps have 1, and Effect › Warp edits keep each Warp's own version.
These commands refuse any other value with `` `version` must be 0 or 1 ``; `1.0` counts as 1. An `.ai` file holds a
warp's settings and not its version, so a `version` 0 warp saved to `.ai` and opened again draws with `version` 1.
These patches are VectorCraft's own rules; the styles and the ranges of Bend and the distortions are those of the Warp
options (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
`object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and
the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected,
`object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps
+1
View File
@@ -588,6 +588,7 @@ since 2026-10-01.
- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged.
- **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim.
- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then 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, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged.
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.