Merge pull request #7064 from dotCooCoo/feat/envelope-warp-points

Envelopes: the Mesh tool and Direct Selection edit a warp envelope's points
This commit is contained in:
BFlat
2026-11-13 15:04:18 +07:00
committed by GitHub
14 changed files with 773 additions and 62 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
set_type_ink, smooth_handles,
set_type_ink, smooth_handles, warp_grid,
};
pub(crate) use expand::max_outset;
pub use expand::{
+250 -8
View File
@@ -525,21 +525,106 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> {
doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. })))
}
/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid,
/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to
/// edit (its points and handles).
/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on its
/// [`warp_grid`], a mesh envelope's own grid) or its top object's outline, and for a warp or mesh
/// envelope the grid to edit (its points and handles; none while its contents are edited).
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
let NodeKind::Envelope { kind, editing, .. } = &n.kind else { return None };
let points = |grid: GradientMesh| (!*editing).then_some(grid);
match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
let grid = envelope_grid(*rows, *cols, points, handles)?;
Some((grid.lines(), Some(grid)))
EnvelopeKind::Mesh { rows, cols, points: at, handles } => {
let grid = envelope_grid(*rows, *cols, at, handles)?;
Some((grid.lines(), points(grid)))
}
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
EnvelopeKind::Warp { .. } => match warp_grid(n) {
Some(grid) => Some((grid.lines(), points(grid))),
None => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
},
EnvelopeKind::TopObject { path, .. } => Some((path.clone(), None)),
}
}
/// The grid sizes [`warp_grid`] tries, in order: patches per row and per column.
const WARP_GRIDS: [u32; 3] = [4, 8, 16];
/// How far a [`warp_grid`] may draw from the warp it replaces, as a share of the diagonal of the
/// warp's bounds.
const WARP_GRID_TOLERANCE: f64 = 1e-3;
/// The grid a warp envelope's points and handles are edited on: its surface sampled on the first
/// of 4×4, 8×8 and 16×16 patches whose curves draw within [`WARP_GRID_TOLERANCE`] of the warp, with
/// handles along it, as a gradient mesh with black points. Editing a point makes the envelope a
/// mesh envelope with this grid. `None` for other nodes, for content with no bounds, and for a warp
/// that no such grid follows closely enough or whose grid isn't finite.
/// Built again only when the envelope's content objects, warp, frame or options change, or what
/// type is measured with ([`set_type_ink`]).
pub fn warp_grid(n: &Node) -> Option<GradientMesh> {
let NodeKind::Envelope { content, kind, frame, options, .. } = &n.kind else { return None };
if !matches!(**kind, EnvelopeKind::Warp { .. }) {
return None;
}
// `None` until a measure is registered, so a grid kept before then is built again after.
let inputs = TYPE_INK.get().map(|(_, inputs)| inputs());
let key: Vec<usize> = content.iter().map(|c| Arc::as_ptr(c) as usize).collect();
let same = |k: &KeptGrid| k.kind == **kind && k.frame == **frame && k.options == *options && k.inputs == inputs;
if let Some(grid) = WARP_GRIDS_KEPT.with_borrow(|kept| kept.get(&key).filter(|k| same(k)).map(|k| k.grid.clone())) {
return grid;
}
let grid = build_warp_grid(n);
WARP_GRIDS_KEPT.with_borrow_mut(|kept| {
if kept.len() >= WARP_GRID_KEPT {
// Entries for content that no longer exists go first, then one entry at a time.
kept.retain(|_, k| k.content.iter().all(|c| c.strong_count() > 0));
if kept.len() >= WARP_GRID_KEPT
&& let Some(any) = kept.keys().next().cloned()
{
kept.remove(&any);
}
}
// The weak pointers keep the content's addresses from being reused while the entry is kept.
let kept_content = content.iter().map(Arc::downgrade).collect();
kept.insert(key, KeptGrid { content: kept_content, kind: (**kind).clone(), frame: **frame, options: *options, inputs, grid: grid.clone() });
});
grid
}
/// A grid [`warp_grid`] built, with what it was built from.
struct KeptGrid {
content: Vec<std::sync::Weak<Node>>,
kind: EnvelopeKind,
frame: Affine,
options: EnvelopeOptions,
inputs: Option<u64>,
grid: Option<GradientMesh>,
}
/// How many grids [`warp_grid`] keeps on one thread.
const WARP_GRID_KEPT: usize = 1024;
std::thread_local! {
/// The grids [`warp_grid`] built on this thread, by the addresses of their envelopes' content.
static WARP_GRIDS_KEPT: std::cell::RefCell<std::collections::HashMap<Vec<usize>, KeptGrid>> = Default::default();
}
/// [`warp_grid`], built from scratch.
fn build_warp_grid(n: &Node) -> Option<GradientMesh> {
let map = EnvelopeMap::of(n)?;
let bounds = map.bounds()?;
let tol = bounds.width().hypot(bounds.height()) * WARP_GRID_TOLERANCE;
if !tol.is_finite() {
return None;
}
WARP_GRIDS.into_iter().find_map(|k| {
let m = map.surface_mesh(k, k, Color::BLACK);
let finite = m.points.iter().all(|q| q.p.is_finite() && q.handles.iter().all(|h| h.is_finite()));
// Each patch is checked at its corners, the middles of its sides and its middle.
let steps = 2 * k as usize;
let at = |i: usize| i as f64 / steps as f64;
let fits = || (0..=steps).all(|j| (0..=steps).all(|i| mesh_at(&m, at(i), at(j)).distance(map.at(at(i), at(j))) <= tol));
(finite && fits()).then_some(m)
})
}
/// The frame an envelope keeps after transform `m` (its frame so far composed with the
/// transform): identity when its axes stay square to the page, unflipped, since evaluating on
/// the page axes gives the same result then.
@@ -760,6 +845,163 @@ mod tests {
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
}
/// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at
/// (100, 50).
fn warp_node(style: &str, bend: f64, h: f64, version: u32) -> Node {
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 300.0, 150.0));
let content = Arc::new(Node::path(NodeId(1), rect, crate::Appearance::default_art()));
let kind = EnvelopeKind::Warp { style: style.into(), bend, h, v: 0.0, horizontal: true, version };
Node::new(
NodeId(2),
NodeKind::Envelope {
content: vec![content],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Box::new(Affine::IDENTITY),
},
)
}
const STYLES: [&str; 15] = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
/// Every warp style's Bézier net has a grid to edit: its points lie on the warp, and the grid
/// draws within a thousandth of the warp's diagonal of it, between the grid lines too. The
/// overlay shows that grid's lines and points.
#[test]
fn every_warp_style_has_a_grid_that_keeps_its_shape() {
for style in STYLES {
for (bend, h) in [(50.0, 0.0), (100.0, 0.0), (-100.0, 60.0)] {
let n = warp_node(style, bend, h, crate::live::WARP_NET);
let grid = warp_grid(&n).unwrap_or_else(|| panic!("{style} {bend} {h}: no grid"));
assert!(WARP_GRIDS.contains(&grid.rows) && grid.rows == grid.cols, "{style}: {}×{}", grid.rows, grid.cols);
let map = EnvelopeMap::of(&n).unwrap();
let b = map.bounds().unwrap();
let tol = b.width().hypot(b.height()) * 1e-3;
let k = grid.cols as usize;
for (i, q) in grid.points.iter().enumerate() {
let want = map.at((i % (k + 1)) as f64 / k as f64, (i / (k + 1)) as f64 / k as f64);
assert!(q.p.distance(want) < 1e-9, "{style}: point {i} at {:?}, the warp at {want:?}", q.p);
}
for j in 0..=20 {
for i in 0..=20 {
let (u, v) = (i as f64 / 20.0, j as f64 / 20.0);
let off = mesh_at(&grid, u, v).distance(map.at(u, v));
assert!(off <= tol, "{style} {bend} {h}: {off} off at ({u}, {v})");
}
}
let (lines, shown) = envelope_overlay(&n).unwrap();
assert_eq!(shown.as_ref(), Some(&grid), "{style}");
assert_eq!(lines, grid.lines(), "{style}");
}
}
}
/// A kept warp grid is built again when the content, the warp, the frame or the options change,
/// and is the grid built from scratch otherwise.
#[test]
fn a_kept_warp_grid_follows_changes() {
let n = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET);
let first = warp_grid(&n);
assert!(first.is_some());
assert_eq!(warp_grid(&n), first);
assert_eq!(build_warp_grid(&n), first);
let changed = |f: &dyn Fn(&mut Node)| {
let mut m = n.clone();
f(&mut m);
let grid = warp_grid(&m);
assert_eq!(grid, build_warp_grid(&m));
assert_ne!(grid, first);
};
changed(&|m| {
if let NodeKind::Envelope { content, .. } = &mut m.kind {
Arc::make_mut(&mut content[0]).transform(Affine::translate((40.0, 0.0)), true);
}
});
changed(&|m| {
if let NodeKind::Envelope { kind, .. } = &mut m.kind
&& let EnvelopeKind::Warp { bend, .. } = &mut **kind
{
*bend = -30.0;
}
});
changed(&|m| {
if let NodeKind::Envelope { frame, .. } = &mut m.kind {
**frame = Affine::rotate(0.3);
}
});
changed(&|m| {
if let NodeKind::Envelope { options, .. } = &mut m.kind {
options.bounds_margin = [0.1; 4];
}
});
}
/// The table of kept warp grids stays bounded: full of envelopes that still exist, it drops one
/// entry at a time instead of emptying, and it drops the entries of envelopes that are gone
/// first.
#[test]
fn the_kept_warp_grids_stay_bounded() {
let nodes: Vec<Node> = (0..WARP_GRID_KEPT + 10).map(|i| warp_node("arc", (i % 100) as f64, 0.0, crate::live::WARP_NET)).collect();
for n in &nodes {
assert!(warp_grid(n).is_some());
}
assert_eq!(WARP_GRIDS_KEPT.with_borrow(|kept| kept.len()), WARP_GRID_KEPT);
// Once the envelopes are gone, their entries are the ones dropped.
drop(nodes);
assert!(warp_grid(&warp_node("arc", 10.0, 0.0, crate::live::WARP_NET)).is_some());
assert_eq!(WARP_GRIDS_KEPT.with_borrow(|kept| kept.len()), 1);
}
/// A warp drawn by the old point map that no grid of up to 16×16 patches follows closely
/// enough, content without bounds and other envelopes have no warp grid; the overlay still
/// draws the old warp's lines. While an envelope's contents are edited, it shows no points.
#[test]
fn a_warp_no_grid_follows_has_no_grid_to_edit() {
let n = warp_node("fisheye", 100.0, 0.0, 0);
assert!(warp_grid(&n).is_none());
let (lines, grid) = envelope_overlay(&n).unwrap();
assert!(grid.is_none());
assert!(lines.anchor_count() > 0);
// The same style on its Bézier net has one.
assert!(warp_grid(&warp_node("fisheye", 100.0, 0.0, crate::live::WARP_NET)).is_some());
let mut empty = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET);
if let NodeKind::Envelope { content, .. } = &mut empty.kind {
content.clear();
}
assert!(warp_grid(&empty).is_none());
let mut mesh = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET);
if let NodeKind::Envelope { kind, .. } = &mut mesh.kind {
**kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(0.0, 0.0, 1.0, 1.0), 1, 1), handles: vec![] };
}
assert!(warp_grid(&mesh).is_none());
// While the contents are edited, a warp's and a mesh envelope's lines show without points.
for mut n in [warp_node("arc", 50.0, 0.0, crate::live::WARP_NET), mesh] {
if let NodeKind::Envelope { editing, .. } = &mut n.kind {
*editing = true;
}
let (lines, grid) = envelope_overlay(&n).unwrap();
assert!(grid.is_none() && lines.anchor_count() > 0);
}
}
/// A table that fills up is emptied instead of growing, and what is looked up after that is
/// measured again.
#[test]
+49
View File
@@ -0,0 +1,49 @@
//! A warp grid kept before type's measure is registered is built again once it is, even when the
//! registered inputs number is 0. One test in its own process, so nothing has registered the
//! measure before it runs.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use vectorcraft_doc::live::{EnvelopeOptions, set_type_ink, warp_grid};
use vectorcraft_doc::{CharStyle, EnvelopeKind, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, Point, Rect};
/// A measure that gives every type the same box, well away from its laid-out box.
fn fixed_ink(_: &TextObject, _: Affine, _: Option<(vectorcraft_geom::Homography, Affine)>) -> Option<Rect> {
Some(Rect::new(0.0, 0.0, 400.0, 300.0))
}
fn no_inputs() -> u64 {
0
}
/// An arc warp around `t`, its content in a new `Arc`.
fn warp_of(t: &TextObject) -> Node {
let content = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 1 };
Node::new(
NodeId(2),
NodeKind::Envelope {
content: vec![content],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Box::new(Affine::IDENTITY),
},
)
}
#[test]
fn a_warp_grid_kept_before_the_type_measure_is_built_again() {
let t = TextObject::point(Point::new(100.0, 100.0), "Type", CharStyle::default());
let n = warp_of(&t);
let before = warp_grid(&n);
set_type_ink(fixed_ink, no_inputs);
let fresh = warp_grid(&warp_of(&t));
assert!(fresh.is_some());
assert_ne!(fresh, before, "the registered measure moves the grid");
assert_eq!(warp_grid(&n), fresh, "the grid kept from before the registration is built again");
}
+6 -6
View File
@@ -244,7 +244,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Envelope Mesh Point",
[],
None,
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too",
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh or warp envelope (its handles follow), or with `handle` place that handle end at (x, y); a warp's points are those of its grid (4×4 patches, or 8×8 or 16×16 where 4×4 draws more than 0.1% of its diagonal away from the warp), and its first edit makes it a mesh envelope on that grid in the same undo step (a warp no such grid follows is refused: Reset with Mesh first); object.mesh.movePoint, movePoints, addLine and deletePoint edit mesh and warp envelopes too",
has_doc,
env_set_mesh_point
),
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Mesh Point Color",
[],
None,
"{id, index | points: [index…] | items: [{id, index | points}…], color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing)",
"{id, index | points: [index…] | items: [{id, index | points}…], color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing, and so does a call that changes no point, with no undo step)",
has_doc,
mesh_set_color
),
@@ -272,7 +272,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Mesh Point",
[],
None,
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)",
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or a mesh or warp envelope (its handles follow; a warp becomes a mesh envelope, see object.envelope.setMeshPoint), or with `handle` place that handle end at (x, y)",
has_doc,
mesh_move_point
),
@@ -281,7 +281,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Mesh Points",
[],
None,
"{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh envelopes by (dx, dy), their handles with them (one undo step; an index out of range changes nothing)",
"{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh or warp envelopes by (dx, dy), their handles with them (warps become mesh envelopes, see object.envelope.setMeshPoint; one undo step; an index out of range changes nothing, and so does a call that moves no point, with no undo step)",
has_doc,
mesh_move_points
),
@@ -290,7 +290,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Mesh Line",
[],
None,
"{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (a mesh envelope's points have no color) → {index}",
"{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh or warp envelope (a warp becomes a mesh envelope, see object.envelope.setMeshPoint); a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (an envelope's points have no color) → {index}",
has_doc,
mesh_add_line
),
@@ -299,7 +299,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Mesh Point",
[],
None,
"{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope",
"{id, index} delete the mesh lines through a point of a gradient mesh or a mesh or warp envelope (a warp becomes a mesh envelope, see object.envelope.setMeshPoint)",
has_doc,
mesh_delete_point
),
+15 -3
View File
@@ -1,6 +1,6 @@
use super::super::object::choice;
use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap};
use super::mesh::{handle_param, index_param, whole};
use super::mesh::{envelope_edit_grid, handle_param, index_param, mesh_envelope, warp_point_stays, whole};
use super::{
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param,
@@ -79,8 +79,7 @@ fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> Envelop
return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] };
}
// The surface's points with handles along it: the grid lines keep the current shape.
let m = map.surface_mesh(rows, cols, Color::BLACK);
EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() }
mesh_envelope(&map.surface_mesh(rows, cols, Color::BLACK))
}
pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
@@ -428,7 +427,20 @@ pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
// A warp whose point or handle stays where it is stays a warp.
if warp_point_stays(&s.doc()?.doc, id, index, handle, q, C)? {
return ok();
}
s.edit("Move Envelope Point", |d, _| {
// A warp's first edited point makes it a mesh envelope on its grid, in the same step.
if let Some(n) = d.node(id)
&& matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))
{
let grid = envelope_edit_grid(n, C).ok_or_else(|| bad(C, "not a mesh envelope point"))??;
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
**kind = mesh_envelope(&grid);
}
}
let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else {
return Err(bad(C, "not a mesh envelope point"));
};
+108 -30
View File
@@ -119,31 +119,90 @@ fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
}
/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its
/// points and handles are written back: editing fills in the handles).
fn edit_grid<T>(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
edit_kind(&mut d.node_mut(id).ok_or(EngineError::NoNode(id))?.kind, cmd, envelopes, f)
/// The grid an edit of envelope `n`'s points starts from: a mesh envelope's own grid, or a warp's
/// grid ([`live::warp_grid`]), which the edit turns into a mesh envelope. `None` for other nodes.
pub(super) fn envelope_edit_grid(n: &Node, cmd: &str) -> Option<Result<GradientMesh>> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
Some(live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed")))
}
EnvelopeKind::Warp { .. } => {
Some(live::warp_grid(n).ok_or_else(|| bad(cmd, "this warp's points can't be edited: use Reset with Mesh first")))
}
EnvelopeKind::TopObject { .. } => None,
}
}
/// [`edit_grid`] on a node's kind.
fn edit_kind<T>(k: &mut NodeKind, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
match k {
NodeKind::Mesh(m) => f(m),
NodeKind::Envelope { kind, .. } if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => {
let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else {
return Err(bad(cmd, "not a gradient mesh or mesh envelope"));
};
let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))?;
let r = f(&mut m)?;
(*rows, *cols) = (m.rows, m.cols);
*points = m.points.iter().map(|q| q.p).collect();
*handles = m.points.iter().map(|q| q.handles).collect();
Ok(r)
}
_ => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
/// The mesh envelope kind of grid `m`, with its points' handles.
pub(super) fn mesh_envelope(m: &GradientMesh) -> EnvelopeKind {
EnvelopeKind::Mesh {
rows: m.rows,
cols: m.cols,
points: m.points.iter().map(|q| q.p).collect(),
handles: m.points.iter().map(|q| q.handles).collect(),
}
}
/// Does placing point `index` of warp envelope `id` of `d`, or that point's `handle` end, at `q`
/// leave it where it is? Such a placement is no edit. `false` for other objects and for an index
/// out of range, whose edits run as before.
pub(super) fn warp_point_stays(d: &Document, id: NodeId, index: usize, handle: Option<usize>, q: Point, cmd: &str) -> Result<bool> {
let Some(n) = d.node(id).filter(|n| matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))) else {
return Ok(false);
};
let grid = envelope_grid_to_edit(n, cmd, true)?;
Ok(grid.points.get(index).is_some_and(|pt| match handle {
// The caller sends a handle's end as point plus handle; `q - p` can round away from it.
Some(h) => pt.handles.get(h).is_some_and(|v| pt.p + *v == q),
None => pt.p == q,
}))
}
/// The grid of envelope `n` that [`edit_grid`] edits (only `envelopes`), or the error it fails with.
fn envelope_grid_to_edit(n: &Node, cmd: &str, envelopes: bool) -> Result<GradientMesh> {
match envelope_edit_grid(n, cmd).filter(|_| envelopes) {
Some(grid) => grid,
None => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
}
}
/// The grid of mesh `id` of `d` that [`edit_grid`] edits, as it is now.
fn grid_to_edit(d: &Document, id: NodeId, cmd: &str, envelopes: bool) -> Result<GradientMesh> {
match d.node(id) {
Some(Node { kind: NodeKind::Mesh(m), .. }) => Ok(m.clone()),
Some(n) => envelope_grid_to_edit(n, cmd, envelopes),
None => Err(EngineError::NoNode(id)),
}
}
/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a warp or mesh envelope's grid too
/// (see [`edit_node`]).
fn edit_grid<T>(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
edit_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, cmd, envelopes, f)
}
/// [`edit_grid`] on a node: with `envelopes`, a warp or mesh envelope's grid
/// ([`envelope_edit_grid`]) is edited too, and its points and handles are written back as a mesh
/// envelope (editing fills in the handles, and makes a warp a mesh envelope).
fn edit_node<T>(n: &mut Node, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
if let NodeKind::Mesh(m) = &mut n.kind {
return f(m);
}
let start = envelope_grid_to_edit(n, cmd, envelopes)?;
let mut m = start.clone();
let r = f(&mut m)?;
// A warp stays a warp when the edit changed nothing.
let warp = matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }));
if warp && m == start {
return Ok(r);
}
if let NodeKind::Envelope { kind, .. } = &mut n.kind {
**kind = mesh_envelope(&m);
}
Ok(r)
}
/// Most mesh points one command names, over all its meshes.
const MAX_POINTS: usize = 1 << 20;
@@ -178,7 +237,8 @@ fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
}
/// Run `f` on each mesh point `items` names, as one undo step `label` (an index out of range
/// fails it, changing nothing); with `envelopes`, on mesh envelopes' points too.
/// fails it, changing nothing); with `envelopes`, on warp and mesh envelopes' points too. When `f`
/// changes no point, nothing changes and no undo step is recorded.
fn with_points(
s: &mut Session,
items: &[(NodeId, BTreeSet<usize>)],
@@ -187,6 +247,20 @@ fn with_points(
envelopes: bool,
mut f: impl FnMut(&mut live::MeshPoint) -> Result<()>,
) -> Result<()> {
let doc = &s.doc()?.doc;
let mut changes = false;
for (id, points) in items {
let mut m = grid_to_edit(doc, *id, cmd, envelopes)?;
for i in points {
let q = m.points.get_mut(*i).ok_or_else(|| bad(cmd, "index out of range"))?;
let before = q.clone();
f(q)?;
changes |= *q != before;
}
}
if !changes {
return Ok(());
}
s.edit(label, |d, _| {
for (id, points) in items {
edit_grid(d, *id, cmd, envelopes, |m| {
@@ -255,6 +329,10 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
if warp_point_stays(&s.doc()?.doc, id, index, handle, q, C)? {
return ok();
}
with_mesh(s, p, C, "Move Mesh Point", true, |m| {
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
match handle {
@@ -298,12 +376,12 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
}
// The lines are added to a copy of the object. A point that is already a mesh point adds none:
// when its color stays too, nothing changes and no undo step is recorded. The grid is compared,
// not the node: a mesh envelope stored without handles takes them in any edit, and keeps no
// colors, so only its points and handles count.
let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.kind.clone();
let colored = matches!(orig, NodeKind::Mesh(_));
let mut kind = orig.clone();
let (i, same) = edit_kind(&mut kind, C, true, |m| {
// not the node: a mesh envelope stored without handles takes them in any edit, a warp becomes
// a mesh envelope, and neither keeps colors, so only their points and handles count.
let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
let colored = matches!(orig.kind, NodeKind::Mesh(_));
let mut node = orig.clone();
let (i, same) = edit_node(&mut node, C, true, |m| {
let before = m.clone();
let i = add(m)?;
let shape = |g: &GradientMesh| (g.rows, g.cols, g.points.iter().map(|q| (q.p, q.handles)).collect::<Vec<_>>());
@@ -315,10 +393,10 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
let index = s.edit("Add Mesh Line", move |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
// Settling typed values before the edit may have changed the object: add the lines again.
if n.kind != orig {
return edit_kind(&mut n.kind, C, true, add);
if n.kind != orig.kind {
return edit_node(n, C, true, add);
}
n.kind = kind;
n.kind = node.kind;
Ok(i)
})?;
Ok(json!({ "index": index }))
+233
View File
@@ -151,6 +151,239 @@ fn mesh_handles_turn_with_the_envelope_and_round_trip() {
assert!(matches!(n.kind, NodeKind::Envelope { kind, .. } if matches!(&*kind, EnvelopeKind::Mesh { handles, .. } if handles.is_empty())));
}
/// The kind of envelope `e`.
fn kind(s: &Session, e: NodeId) -> EnvelopeKind {
match node(s, e).kind {
NodeKind::Envelope { kind, .. } => *kind,
k => panic!("{k:?}"),
}
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Moving a point of a warp envelope makes it a mesh envelope on the warp's grid with that point
/// moved, as one undo step: the other points and every handle stay where the warp had them, and
/// the shape away from the moved point stays the warp's. Undo gives back the warp, Redo the edit.
#[test]
fn moving_a_warp_point_makes_it_a_mesh_envelope_in_one_step() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let warp = node(&s, e);
let grid = live::warp_grid(&warp).unwrap();
assert_eq!((grid.rows, grid.cols), (4, 4));
let before = EnvelopeMap::of(&warp).unwrap();
let n = undo_len(&s);
// An interior point.
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": 6, "x": 160, "y": 120})).unwrap();
assert_eq!(undo_len(&s), n + 1, "one undo step");
let (rows, cols, points, handles) = mesh(&s, e);
assert_eq!((rows, cols, points.len(), handles.len()), (4, 4, 25, 25));
for (i, q) in grid.points.iter().enumerate() {
let want = if i == 6 { Point::new(160.0, 120.0) } else { q.p };
assert!(points[i].distance(want) < 1e-9, "point {i}: {:?} vs {want:?}", points[i]);
assert_eq!(handles[i], q.handles, "handles of point {i}");
}
let moved = node(&s, e);
let after = EnvelopeMap::of(&moved).unwrap();
let b = before.src();
let tol = b.width().hypot(b.height()) * 1e-3;
for (u, v) in [(0.9, 0.9), (0.6, 0.875), (1.0, 0.5), (0.0, 1.0)] {
assert!(after.at(u, v).distance(before.at(u, v)) <= tol, "({u}, {v}): {:?} vs {:?}", after.at(u, v), before.at(u, v));
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, e), warp, "Undo gives back the warp, style and bend included");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(mesh(&s, e).2[6], Point::new(160.0, 120.0));
// A corner of a warp, and a handle with object.envelope.setMeshPoint.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [0], "dx": -10, "dy": -5})).unwrap();
assert!(mesh(&s, e).2[0].distance(grid.points[0].p + Vec2::new(-10.0, -5.0)) < 1e-9);
s.execute("edit.undo", &json!({})).unwrap();
let end = grid.points[12].p + Vec2::new(30.0, -30.0);
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 12, "x": end.x, "y": end.y, "handle": 0})).unwrap();
let (_, _, points, handles) = mesh(&s, e);
assert!((points[12] + handles[12][live::H_RIGHT]).distance(end) < 1e-9);
assert_eq!(handles[12][live::H_LEFT], grid.points[12].handles[live::H_LEFT]);
assert_eq!(undo_len(&s), n + 1);
}
/// After a warp's point is edited, the envelope keeps its content, options and fidelity; it saves
/// and opens again as it is; Reset with Mesh with Maintain Shape keeps the edited shape and Reset
/// with Warp gives the chosen style; Release gives back the edited mesh as its shape and Expand
/// keeps the edited distortion.
#[test]
fn an_edited_warp_saves_resets_releases_and_expands_as_edited() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40}));
s.execute("object.envelope.options", &json!({"fidelity": 80, "distortAppearance": false})).unwrap();
let warp = node(&s, e);
// The middle of the bottom edge, 30 down.
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [22], "dx": 0, "dy": 30})).unwrap();
let edited = node(&s, e);
let (NodeKind::Envelope { content: c0, options: o0, fidelity: f0, .. }, NodeKind::Envelope { content: c1, options: o1, fidelity: f1, .. }) =
(&warp.kind, &edited.kind)
else {
panic!("envelopes")
};
assert_eq!((c0, o0, f0), (c1, o1, f1), "content, options and fidelity stay");
let d = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
assert_eq!(back.node(e), d.node(e), "a save opens again as edited");
// Reset with Mesh, keeping the shape: the edited points.
let (_, _, points, _) = mesh(&s, e);
s.execute("object.envelope.resetWithMesh", &json!({"maintainShape": true})).unwrap();
let (rows, cols, kept, _) = mesh(&s, e);
assert_eq!((rows, cols), (4, 4));
for (p, q) in points.iter().zip(&kept) {
assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}");
}
// Reset with Warp: the chosen style replaces the edit.
s.execute("object.envelope.resetWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { style, bend, .. } if style == "bulge" && bend == 30.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, e), edited);
// Release: the content and the edited mesh as the envelope's shape.
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
let ids = s.execute("object.envelope.release", &json!({})).unwrap();
let ids: Vec<NodeId> = ids["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
let shape = ids.iter().find_map(|id| match node(&s, *id).kind {
NodeKind::Mesh(m) => Some(m),
_ => None,
});
let shape = shape.expect("the envelope's shape");
assert_eq!(shape.points.iter().map(|q| q.p).collect::<Vec<_>>(), points);
s.execute("edit.undo", &json!({})).unwrap();
// Expand: the distorted content follows the moved point, unlike the warp's.
let expanded = |s: &mut Session| {
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
s.execute("object.envelope.expand", &json!({})).unwrap();
let b = node(s, e).geometric_bounds().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
b
};
let edited_bounds = expanded(&mut s);
s.execute("edit.undo", &json!({})).unwrap();
let warp_bounds = expanded(&mut s);
assert!(edited_bounds.y1 > warp_bounds.y1 + 5.0, "{edited_bounds:?} vs {warp_bounds:?}");
}
/// An edit of a warp's points that fails (an index out of range, here on the second of two
/// warps) changes neither warp and adds no undo step; a warp that no grid follows closely enough
/// shows no points and is refused, and so is a top object envelope.
#[test]
fn a_warp_point_edit_that_fails_changes_nothing() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let (_, f) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "wave", "bend": 50}));
let (we, wf, n) = (node(&s, e), node(&s, f), undo_len(&s));
let r = s.execute("object.mesh.movePoints", &json!({"items": [{"id": e.0, "points": [0]}, {"id": f.0, "points": [3, 99999]}], "dx": 5, "dy": 5}));
assert!(r.is_err(), "{r:?}");
let r = s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 25, "x": 0, "y": 0}));
assert!(r.is_err(), "{r:?}");
assert_eq!((node(&s, e), node(&s, f), undo_len(&s)), (we, wf, n), "nothing changed");
// The old point map's fisheye: no grid of up to 16×16 patches follows it.
let (_, g) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "fisheye", "bend": 100, "version": 0}));
let old = node(&s, g);
assert!(live::warp_grid(&old).is_none());
assert!(live::envelope_overlay(&old).unwrap().1.is_none(), "no points show");
let n = undo_len(&s);
for (cmd, p) in [
("object.mesh.movePoint", json!({"id": g.0, "index": 0, "x": 0, "y": 0})),
("object.envelope.setMeshPoint", json!({"id": g.0, "index": 0, "x": 0, "y": 0})),
("object.mesh.addLine", json!({"id": g.0, "x": 200, "y": 150})),
] {
let r = s.execute(cmd, &p);
assert!(
matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "this warp's points can't be edited: use Reset with Mesh first"),
"{cmd}: {r:?}"
);
}
assert_eq!((node(&s, g), undo_len(&s)), (old, n));
}
/// An edit that moves no point of a warp (no points named, a move by nothing, a point placed where
/// it is, a line added through one of its points) leaves it a warp and records no undo step.
#[test]
fn a_warp_edit_that_moves_nothing_leaves_the_warp() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let (warp, n) = (node(&s, e), undo_len(&s));
let grid = live::warp_grid(&warp).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [], "dx": 10, "dy": 0})).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [3, 6], "dx": 0, "dy": 0})).unwrap();
assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n));
let q = grid.points[6].clone();
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": 6, "x": q.p.x, "y": q.p.y})).unwrap();
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 6, "x": q.p.x, "y": q.p.y})).unwrap();
// A line added through a point of the warp's grid adds none.
let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": q.p.x, "y": q.p.y})).unwrap();
assert_eq!(r["index"], json!(6));
assert_eq!((node(&s, e), undo_len(&s)), (warp, n));
// An unknown mesh still fails.
assert!(s.execute("object.mesh.movePoints", &json!({"id": 99999, "points": [], "dx": 1, "dy": 0})).is_err());
}
/// A handle placed at its own end, sent as point plus handle, leaves a warp a warp, even where
/// `(p + v) - p` rounds to a value other than `v`.
#[test]
fn a_warp_handle_placed_at_its_end_leaves_the_warp() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let (warp, n) = (node(&s, e), undo_len(&s));
let grid = live::warp_grid(&warp).unwrap();
let mut rounds = 0;
for (index, pt) in grid.points.iter().enumerate() {
for (h, v) in pt.handles.iter().enumerate() {
let end = pt.p + *v;
if end - pt.p != *v {
rounds += 1;
}
for cmd in ["object.mesh.movePoint", "object.envelope.setMeshPoint"] {
s.execute(cmd, &json!({"id": e.0, "index": index, "x": end.x, "y": end.y, "handle": h})).unwrap();
assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n), "{cmd}: point {index}, handle {h}");
}
}
}
assert!(rounds > 0, "the grid has a handle whose end does not give the handle back");
}
/// With Direct Selection, a selected warp's point drags as `object.mesh.movePoint`: the preview
/// makes it a mesh envelope, Escape gives back the warp with no undo step, and a finished drag is
/// one undo step. While its contents are edited, its points don't take the press.
#[test]
fn direct_selection_drags_a_warps_point_and_escape_gives_it_back() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50}));
let warp = node(&s, e);
let corner = live::warp_grid(&warp).unwrap().points[24].p;
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("directSelection", v).unwrap();
let n = undo_len(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert!(mesh(&s, e).2[24].distance(corner + Vec2::new(20.0, 10.0)) < 1e-9, "the preview moves the point");
s.tool_key(vectorcraft_tools::ToolKey::Escape, vectorcraft_tools::Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n), "Escape gives back the warp");
s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert!(mesh(&s, e).2[24].distance(corner + Vec2::new(20.0, 10.0)) < 1e-9);
// Edit Contents: the press doesn't take the envelope's point.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { .. }), "{:?}", kind(&s, e));
}
#[test]
fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() {
let mut s = session();
+6 -1
View File
@@ -1136,7 +1136,12 @@ fn mesh_points_move_and_colour_together() {
assert!(s.execute("object.mesh.movePoints", &p).is_err(), "{p}");
}
assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [0, 9], "color": "#000000"})).is_err());
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "nothing changed");
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before.clone(), n), "nothing changed");
// A call that changes no point records no undo step.
s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [], "dx": 1, "dy": 1})).unwrap();
s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0, 1], "dx": 0, "dy": 0})).unwrap();
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [2, 6], "color": "#0000ff", "opacity": 0.25})).unwrap();
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "no change, no undo step");
}
#[test]
+1 -1
View File
@@ -28,7 +28,7 @@
//! Direct Selection also edits a blend's spine: drag its points (Shift-click picks several, which
//! drag together; a key object on a point moves with it) and, once a point is clicked, its
//! handles; and the points of selected gradient meshes
//! and mesh envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
//! and of mesh and warp envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
//! a click on a gradient mesh's line picks its two ends and one inside a patch the four points
//! around it, a drag of picked points moves them all, the arrow keys nudge them, and a solid fill
//! colour or the Transparency panel's opacity goes to a gradient mesh's picked points. Dragging a
+5 -5
View File
@@ -21,7 +21,7 @@ use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use crate::blendwidget::{self, BlendWidgetDrag};
use crate::meshedit::{MeshEdit, grid_of};
use crate::meshedit::{MeshEdit, grid_of, may_have_grid};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
@@ -246,13 +246,13 @@ pub struct MeshTool {
}
impl MeshTool {
/// Meshes (gradient meshes and mesh envelopes) to consider for point hits: selected ones
/// first, then any in the document.
/// Meshes (gradient meshes, and mesh and warp envelopes) to consider for point hits: selected
/// ones first, then any in the document.
fn meshes(cx: &ToolContext) -> Vec<NodeId> {
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).and_then(grid_of).is_some()).collect();
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(may_have_grid)).collect();
let mut rest = vec![];
cx.doc.walk(|n| {
if grid_of(n).is_some() {
if may_have_grid(n) {
rest.push(n.id);
}
});
+65 -5
View File
@@ -1,5 +1,5 @@
//! Picking and dragging mesh points and their handles on the canvas, shared by the Mesh tool and
//! Direct Selection: gradient meshes and mesh envelopes alike. A click on a point picks it alone
//! Direct Selection: gradient meshes, mesh envelopes and warp envelopes alike. A click on a point picks it alone
//! and focuses it: its handles show and can be dragged too. Shift-clicks add points to the picked
//! ones or take them away, and Direct Selection's marquee and its clicks on a gradient mesh's line
//! or patch pick points too ([`part_points`]). The picked points show filled; a drag of one of
@@ -11,7 +11,7 @@ use std::borrow::Cow;
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
use vectorcraft_doc::live::{GradientMesh, envelope_grid, warp_grid};
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Vec2};
@@ -19,19 +19,30 @@ use crate::{Action, Mods, Overlay, ToolContext, ToolKey};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
/// The editable grid of `n`: a gradient mesh, or a mesh envelope's grid (not while its contents
/// are being edited).
/// The editable grid of `n`: a gradient mesh, a mesh envelope's grid or a warp envelope's
/// ([`warp_grid`], which its first edit makes a mesh envelope of); not while an envelope's contents
/// are being edited.
pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
match &n.kind {
NodeKind::Mesh(m) => Some(Cow::Borrowed(m)),
NodeKind::Envelope { kind, editing: false, .. } => match &**kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => envelope_grid(*rows, *cols, points, handles).map(Cow::Owned),
_ => None,
EnvelopeKind::Warp { .. } => warp_grid(n).map(Cow::Owned),
EnvelopeKind::TopObject { .. } => None,
},
_ => None,
}
}
/// Can `n` have an editable grid ([`grid_of`])? Its kind alone answers, without building the grid.
pub fn may_have_grid(n: &Node) -> bool {
match &n.kind {
NodeKind::Mesh(_) => true,
NodeKind::Envelope { kind, editing: false, .. } => matches!(**kind, EnvelopeKind::Mesh { .. } | EnvelopeKind::Warp { .. }),
_ => false,
}
}
/// The points of gradient mesh `m` a Direct Selection click at `p` picks: the point within
/// `point_tol`, else the two ends of the mesh line within `line_tol`, else the four points around
/// the patch under `p`.
@@ -433,6 +444,55 @@ mod tests {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);
}
/// The rectangle of [`doc_with_rect`] in an arc warp envelope.
fn doc_with_warp() -> (Document, NodeId) {
let (mut d, e) = doc_with_envelope();
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
**kind =
EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET };
}
(d, e)
}
/// The Mesh tool and Direct Selection drag a warp envelope's points (on its grid) and the
/// clicked point's handles, as they do a mesh envelope's; while its contents are edited, its
/// points take no press.
#[test]
fn the_mesh_tool_and_direct_selection_drag_warp_envelope_points() {
let (mut d, e) = doc_with_warp();
let grid = grid_of(d.node(e).unwrap()).unwrap().into_owned();
assert_eq!(Some(&grid), warp_grid(d.node(e).unwrap()).as_ref());
let mut s = Selection::default();
s.set([e]);
let p = paint();
let c = cx(&d, &s, &p);
let tools: [(&str, Box<dyn Tool>); 2] =
[("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))];
let (i, q) = (6, grid.points[6].clone());
for (what, mut t) in tools {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, q.p.x, q.p.y)), vec![Action::Begin("Move Mesh Point".into())], "{what}");
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, q.p.x + 10.0, q.p.y - 10.0)),
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": i, "x": q.p.x + 10.0, "y": q.p.y - 10.0}))],
"{what}"
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, q.p.x + 10.0, q.p.y - 10.0)), vec![Action::Commit], "{what}");
// The clicked point's handle.
let end = q.p + q.handles[0];
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, end.x, end.y)), vec![Action::Begin("Move Mesh Point".into())], "{what}");
let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, end.x + 5.0, end.y));
assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(0)), "{what}: {drag:?}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, end.x + 5.0, end.y));
}
if let Some(NodeKind::Envelope { editing, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
*editing = true;
}
assert!(grid_of(d.node(e).unwrap()).is_none(), "Edit Contents: no envelope points");
let c = cx(&d, &s, &p);
let mut t = crate::meshblend::create("mesh").unwrap();
assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, q.p.x, q.p.y)), vec![Action::Begin("Move Mesh Point".into())]);
}
#[test]
fn mesh_drag_honors_measurement_labels() {
let (d, _e) = doc_with_envelope();
+15
View File
@@ -497,4 +497,19 @@ mod tests {
assert_eq!(bar_of(&app).map(|b| b.1), Some(Bar::Mesh { rows: 3, cols: 2 }));
bar_frame(&mut app, &ctx, vec![]);
}
#[test]
fn the_control_bar_shows_mesh_rows_and_columns_after_a_warp_point_is_moved() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap();
assert!(bar_of(&app).is_some_and(|(_, bar)| matches!(bar, Bar::Warp { ref style, .. } if style == "arc")));
let (id, warp) = envelope(&app);
let grid = vectorcraft_doc::live::warp_grid(&warp).unwrap();
let p = grid.points[6].p;
app.run("object.mesh.movePoint", json!({"id": id.0, "index": 6, "x": p.x + 10.0, "y": p.y + 10.0})).unwrap();
assert_eq!(bar_of(&app), Some((false, Bar::Mesh { rows: grid.rows, cols: grid.cols })));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
bar_frame(&mut app, &ctx, vec![]);
}
}
+18 -2
View File
@@ -9928,7 +9928,22 @@ adds a row and a column where it clicks inside. `object.mesh.addLine`, and `obje
the mesh's outline (within a millionth of a point) on any side without `tolerance`; a point outside needs `tolerance`. `object.mesh.addLine` at a point that is already a mesh
point adds no line and records no undo step unless its `color` changes a gradient mesh's point (a mesh envelope's points
have no color). `press_key` Escape during a drag of a point or handle of a mesh
envelope or a gradient mesh cancels it: the point goes back and no undo step is added. The Control bar shows Edit Envelope / Edit Contents, the warp's
envelope or a gradient mesh cancels it: the point goes back and no undo step is added.
A warp envelope's points can be edited the same way, with the same commands. Its points are those of its surface
sampled on 4×4 patches, with handles along it; where 4×4 patches draw more than 0.1% of the warp's diagonal away from
the warp, on 8×8 or 16×16 patches (these sizes and the 0.1% are VectorCraft's own values). The canvas shows those
points on a selected warp. The first point or handle moved, or the first line added or deleted, makes the warp a mesh
envelope on that grid and applies the edit, as one undo step: Undo, or Escape during the drag, gives back the warp
with its style and bend. From then on the Control bar shows the mesh's rows and columns, Reset with Mesh with Maintain
Shape keeps the edited shape, and Reset with Warp replaces it with the chosen warp style. Release gives back the
edited mesh as the envelope's shape, and Expand keeps the edited distortion. A warp that no grid of up to 16×16
patches follows that closely (some warps saved before the Bézier warp net existed, `version: 0`) shows no points, and
editing it fails with "this warp's points can't be edited: use Reset with Mesh first" and changes nothing. The help
page on reshaping envelopes:
https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-objects-with-envelopes.html
The Control bar shows Edit Envelope / Edit Contents, the warp's
style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and
Envelope Options.
@@ -9954,7 +9969,8 @@ Color panel, Swatches or the Eyedropper) recolours just the picked points, and w
`object.mesh.movePoints {id, points: [index…], dx, dy}` (or `{items: [{id, points}…], dx, dy}` over several meshes)
moves points by (dx, dy), their handles with them; `object.mesh.setPointColor` takes `points` or `items` the same
way to colour or fade several points at once. An index out of range, an index that is not a whole number ≥ 0, or a
move past the coordinate range fails and changes nothing.
move past the coordinate range fails and changes nothing. A call that changes no point (no points named, a move by
(0, 0), the colour a point already has) changes nothing and records no undo step.
```json
{"name":"run_command","arguments":{"command":"object.mesh.movePoints","params":{"id":12,"points":[0,4],"dx":10,"dy":-5}}}
+1
View File
@@ -1343,6 +1343,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`.
Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own.
Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)).
The Mesh tool and Direct Selection drag the points and handles of an envelope made with Warp, as the help page describes for any envelope ([Distort objects with envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-objects-with-envelopes.html)). A selected warp shows the points of its surface on 4×4 patches (8×8 or 16×16 where 4×4 draws more than 0.1% of its diagonal away from the warp), and the first edit makes it a mesh envelope on that grid in the same undo step; Undo or Escape gives back the warp with its style and bend. The grid sizes, the 0.1% and the change to a mesh envelope are VectorCraft's own rules. Tests: `every_warp_style_has_a_grid_that_keeps_its_shape` in `crates/doc/src/live/envelope.rs`, `moving_a_warp_point_makes_it_a_mesh_envelope_in_one_step` and `an_edited_warp_saves_resets_releases_and_expands_as_edited` in `crates/engine/src/tests/envelope_edit.rs`, `the_mesh_tool_and_direct_selection_drag_warp_envelope_points` in `crates/tools/src/meshedit.rs`.
With Envelope Options › Distort Appearance on, type's strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight instead of bending with the art, as the drawn art of users' `.ai` files strokes type in an envelope. This is VectorCraft's own rule from those files: the public page names applied effects and graphic styles, not strokes. This holds in an envelope inside an envelope too. Paths' strokes, geometry effects (type's included), opacity masks (on type too), linear gradients and pattern fills bend as before; Expand gives type's bent outlines with their strokes on them. The visual bounds of envelopes, blends and repeats, and the reach of symbol art, count type's character strokes as type's own visual bounds do (half the weight of a centred stroke); they left them out, though those strokes already painted past an envelope's grid. Envelopes of stroked type made in VectorCraft before this draw their strokes at their own weight now (#6644; `crates/engine/src/tests/envelope_distort.rs`, `crates/effects/src/bake/tests.rs`).
The four polynomial distributions are original approximations of the public description, not measured
numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html).