mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M8.17: Edit envelopes on the canvas: Edit Contents, mesh points and handles, Control bar
- Edit Contents: while an envelope's contents are edited, clicks hit the content where it sits undistorted and the Selection tool selects it; the menu item reads Edit Envelope then. No default shortcut: the reference app's (Shift+Cmd+V) is Paste in Place here. - A selected envelope shows its mesh (a warp's surface grid, a mesh's own, a top object's outline) instead of its content's outlines. - Mesh envelope points get bezier handles (append-only; none stored is the smooth Catmull-Rom mesh, which the first handle edit fills in exactly along the grid lines). setMeshPoint takes `handle`; object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too. The Mesh tool and Direct Selection drag points and the clicked point's handles (shared MeshEdit); the Mesh tool adds rows and columns inside mesh envelopes. Reset with Mesh keeps the shape along the grid with handles; Release returns the exact mesh. - Control bar: Edit Envelope / Edit Contents, warp style, orientation, bend and distortions or mesh rows and columns, Reset, Envelope Options. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
254251f23a
commit
695c541f0d
17 files changed
+772
-75
No files matched your search
+21
-9
@@ -20,15 +20,18 @@ pub struct Hit {
|
||||
pub leaf: NodeId,
|
||||
pub ancestry: Vec<NodeId>,
|
||||
pub kind: HitKind,
|
||||
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
|
||||
/// its content, not the envelope, is the object clicked.
|
||||
pub contents_of: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
/// The object the Selection tool selects: the child of the layer (the outermost group).
|
||||
/// Inside isolation mode (`scope`), the child of the isolated container instead.
|
||||
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
|
||||
if let Some(s) = scope
|
||||
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
|
||||
{
|
||||
// The innermost of the isolated container and an envelope whose contents are edited.
|
||||
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
|
||||
}
|
||||
// Skip layers and sublayers.
|
||||
@@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
|
||||
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
return Some(h);
|
||||
}
|
||||
chain.pop();
|
||||
@@ -76,6 +80,11 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
|
||||
fn edits_contents(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
|
||||
}
|
||||
|
||||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||||
/// glyph outlines need the font engine, above this crate.
|
||||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
@@ -99,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
if !c.visible || c.locked {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = c.reach_bounds()
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !edits_contents(c)
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
continue;
|
||||
@@ -107,12 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
// as the blend.
|
||||
NodeKind::Blend { .. } => {
|
||||
hit_children(c, p, opt, chain).or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }))
|
||||
}
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
|
||||
+95
-4
@@ -114,7 +114,16 @@ pub enum EnvelopeKind {
|
||||
horizontal: bool,
|
||||
},
|
||||
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
|
||||
Mesh { rows: u32, cols: u32, points: Vec<Point> },
|
||||
Mesh {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
points: Vec<Point>,
|
||||
/// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`.
|
||||
/// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed;
|
||||
/// editing the mesh's points or handles on the canvas fills them in.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
handles: Vec<[Vec2; 4]>,
|
||||
},
|
||||
/// The content's bounding box maps onto the outline of this path.
|
||||
TopObject { path: PathData },
|
||||
}
|
||||
@@ -1081,11 +1090,16 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
|
||||
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
})
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, points } => {
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
if rows == 0 || cols == 0 || points.len() != (rows + 1) * (cols + 1) {
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } => {
|
||||
if !handles.is_empty()
|
||||
&& let Some(m) = envelope_grid(*rows, *cols, points, handles)
|
||||
{
|
||||
return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v));
|
||||
}
|
||||
if *rows == 0 || *cols == 0 || points.len() as u64 != (u64::from(*rows) + 1) * (u64::from(*cols) + 1) {
|
||||
return Box::new(flat);
|
||||
}
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
|
||||
@@ -1102,6 +1116,82 @@ pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box<dyn Fn(Poin
|
||||
Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h))
|
||||
}
|
||||
|
||||
/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third
|
||||
/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves
|
||||
/// an envelope mesh without handles of its own runs along between its points.
|
||||
pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> {
|
||||
let (rows, cols) = (rows as usize, cols as usize);
|
||||
let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default();
|
||||
let tangent = |prev: Option<Point>, p: Point, next: Option<Point>| match (prev, next) {
|
||||
(Some(a), Some(b)) => (b - a) / 2.0,
|
||||
(None, Some(b)) => b - p,
|
||||
(Some(a), None) => p - a,
|
||||
(None, None) => Vec2::ZERO,
|
||||
};
|
||||
let mut out = Vec::with_capacity(points.len());
|
||||
for r in 0..=rows {
|
||||
for c in 0..=cols {
|
||||
let p = at(r, c);
|
||||
let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0;
|
||||
let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0;
|
||||
// Handles pointing out of the grid are unused: they stay at the point.
|
||||
let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO };
|
||||
let mut h = [Vec2::ZERO; 4];
|
||||
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv));
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles
|
||||
/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the
|
||||
/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid.
|
||||
pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option<GradientMesh> {
|
||||
// (Counts come from files: no overflow on 32-bit targets.)
|
||||
if rows == 0 || cols == 0 || points.len() as u64 != (u64::from(rows) + 1) * (u64::from(cols) + 1) {
|
||||
return None;
|
||||
}
|
||||
let smooth;
|
||||
let handles = if handles.len() == points.len() {
|
||||
handles
|
||||
} else {
|
||||
smooth = smooth_handles(rows, cols, points);
|
||||
&smooth
|
||||
};
|
||||
let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect();
|
||||
Some(GradientMesh { rows, cols, points })
|
||||
}
|
||||
|
||||
/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges
|
||||
/// the outer patches go on).
|
||||
fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point {
|
||||
let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64);
|
||||
let (su, sv) = (u * cols as f64, v * rows as f64);
|
||||
let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1));
|
||||
m.eval(r as usize, c as usize, su - c as f64, sv - r as f64)
|
||||
}
|
||||
|
||||
/// The envelope `id` is, or the innermost one it sits in.
|
||||
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).
|
||||
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
|
||||
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } => {
|
||||
let grid = envelope_grid(*rows, *cols, points, handles)?;
|
||||
Some((grid.lines(), Some(grid)))
|
||||
}
|
||||
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
|
||||
EnvelopeKind::TopObject { path } => Some((path.clone(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1461,6 +1551,7 @@ fn expand_envelope(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64, op
|
||||
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
let first = *points.first()?;
|
||||
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
|
||||
|
||||
@@ -652,10 +652,16 @@ impl Node {
|
||||
}
|
||||
*frame = crate::live::envelope_frame(a * *frame);
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
EnvelopeKind::Mesh { points, handles, .. } => {
|
||||
for p in points.iter_mut() {
|
||||
*p = a * *p;
|
||||
}
|
||||
// Handles are offsets: they take the linear part.
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]);
|
||||
for h in handles.iter_mut().flatten() {
|
||||
*h = (lin * h.to_point()).to_vec2();
|
||||
}
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||||
EnvelopeKind::Warp { .. } => {}
|
||||
|
||||
@@ -218,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Envelope Mesh Point",
|
||||
[],
|
||||
None,
|
||||
"{id, index, x, y} move one point of a mesh envelope",
|
||||
"{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",
|
||||
has_doc,
|
||||
env_set_mesh_point
|
||||
),
|
||||
@@ -246,7 +246,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 mesh point (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 mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)",
|
||||
has_doc,
|
||||
mesh_move_point
|
||||
),
|
||||
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Mesh Line",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, color?} add a mesh row and column through (x, y) → {index}",
|
||||
"{id, x, y, color?} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
|
||||
has_doc,
|
||||
mesh_add_line
|
||||
),
|
||||
@@ -264,7 +264,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Mesh Point",
|
||||
[],
|
||||
None,
|
||||
"{id, index} delete the mesh lines through a point",
|
||||
"{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope",
|
||||
has_doc,
|
||||
mesh_delete_point
|
||||
),
|
||||
@@ -958,8 +958,12 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// A mesh envelope of `rows`×`cols` patches for an envelope with map `map`: following its
|
||||
/// current surface (`maintain`), or a flat grid over its content.
|
||||
fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind {
|
||||
let points = if maintain { map.surface_mesh(rows, cols, Color::BLACK).points.into_iter().map(|m| m.p).collect() } else { map.grid(rows, cols) };
|
||||
EnvelopeKind::Mesh { rows, cols, points }
|
||||
if !maintain {
|
||||
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() }
|
||||
}
|
||||
|
||||
fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -969,7 +973,7 @@ fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
|
||||
let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols) };
|
||||
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] };
|
||||
let defaults = envelope_defaults(s);
|
||||
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
@@ -1106,7 +1110,12 @@ fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option<Node> {
|
||||
EnvelopeKind::TopObject { path } => {
|
||||
return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0)));
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
|
||||
// The mesh itself, handles and all.
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } => {
|
||||
let mut m = live::envelope_grid(*rows, *cols, points, handles)?;
|
||||
m.points.iter_mut().for_each(|q| q.color = grey);
|
||||
return Some(Node::new(NodeId(0), NodeKind::Mesh(m)));
|
||||
}
|
||||
EnvelopeKind::Warp { .. } => (4, 4),
|
||||
};
|
||||
EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey))))
|
||||
@@ -1268,10 +1277,24 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let index = f64_req(p, "index", C)? as usize;
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let handle = handle_param(p, C)?;
|
||||
s.edit("Move Envelope Point", |d, _| {
|
||||
match d.node_mut(id).map(|n| &mut n.kind) {
|
||||
Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { points, .. }, .. }) if index < points.len() => points[index] = q,
|
||||
_ => return Err(bad(C, "not a mesh envelope point")),
|
||||
let Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) = d.node_mut(id).map(|n| &mut n.kind) else {
|
||||
return Err(bad(C, "not a mesh envelope point"));
|
||||
};
|
||||
let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?;
|
||||
match handle {
|
||||
// The handles are offsets: they follow.
|
||||
None => points.get_mut(index).into_iter().for_each(|p| *p = q),
|
||||
Some(h) => {
|
||||
// The first handle edited fills in the smooth mesh's handles.
|
||||
if handles.len() != points.len() {
|
||||
*handles = live::smooth_handles(*rows, *cols, points);
|
||||
}
|
||||
if let Some(hs) = handles.get_mut(index) {
|
||||
hs[h] = q - at;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -1333,16 +1356,37 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
|
||||
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let c = cmd.to_string();
|
||||
s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) {
|
||||
Some(NodeKind::Mesh(m)) => f(m),
|
||||
Some(_) => Err(bad(&c, "not a gradient mesh")),
|
||||
Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) if envelopes => {
|
||||
let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "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)
|
||||
}
|
||||
Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
|
||||
None => Err(EngineError::NoNode(id)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any.
|
||||
fn handle_param(p: &Value, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("handle") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(h) if h <= 3 => Ok(Some(h as usize)),
|
||||
_ => Err(bad(cmd, "handle must be 0..3")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
let i = f64_req(p, key, cmd)?;
|
||||
if i < 0.0 {
|
||||
@@ -1359,7 +1403,7 @@ fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if color.is_none() && opacity.is_none() {
|
||||
return Err(bad(C, "missing `color` or `opacity`"));
|
||||
}
|
||||
with_mesh(s, p, C, "Mesh Point Color", |m| {
|
||||
with_mesh(s, p, C, "Mesh Point Color", false, |m| {
|
||||
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
|
||||
if let Some(c) = color {
|
||||
pt.color = c;
|
||||
@@ -1376,11 +1420,8 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.movePoint";
|
||||
let index = index_param(p, "index", C)?;
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let handle = p.get("handle").and_then(Value::as_u64).map(|h| h as usize);
|
||||
if handle.is_some_and(|h| h > 3) {
|
||||
return Err(bad(C, "handle must be 0..3"));
|
||||
}
|
||||
with_mesh(s, p, C, "Move Mesh Point", |m| {
|
||||
let handle = handle_param(p, C)?;
|
||||
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 {
|
||||
Some(h) => pt.handles[h] = q - pt.p,
|
||||
@@ -1395,7 +1436,7 @@ fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.addLine";
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let color = p.get("color").and_then(color_value);
|
||||
let index = with_mesh(s, p, C, "Add Mesh Line", |m| {
|
||||
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
|
||||
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
|
||||
if let Some(c) = color {
|
||||
m.points[i].color = c;
|
||||
@@ -1408,7 +1449,7 @@ fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn mesh_delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.deletePoint";
|
||||
let index = index_param(p, "index", C)?;
|
||||
with_mesh(s, p, C, "Delete Mesh Point", |m| {
|
||||
with_mesh(s, p, C, "Delete Mesh Point", true, |m| {
|
||||
if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) }
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -1220,6 +1220,8 @@ mod tests_envelope;
|
||||
#[cfg(test)]
|
||||
mod tests_envelope_distort;
|
||||
#[cfg(test)]
|
||||
mod tests_envelope_edit;
|
||||
#[cfg(test)]
|
||||
mod tests_expand;
|
||||
#[cfg(test)]
|
||||
mod tests_eyedropper;
|
||||
|
||||
@@ -98,11 +98,14 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
|
||||
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 50, "horizontal": true})).unwrap();
|
||||
let top = live::EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
|
||||
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let EnvelopeKind::Mesh { rows: 2, cols: 2, points } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
|
||||
let EnvelopeKind::Mesh { rows: 2, cols: 2, points, .. } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
|
||||
assert!(points[1].distance(top.points[1].p) < 1e-9 && points[1].y < 99.0, "{:?}", points[1]);
|
||||
// Without Maintain Envelope Shape: a flat grid over the content.
|
||||
s.execute("object.envelope.resetWithMesh", &json!({"rows": 1, "cols": 1, "maintainShape": false})).unwrap();
|
||||
assert_eq!(kind(&s, e), EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1) });
|
||||
assert_eq!(
|
||||
kind(&s, e),
|
||||
EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1), handles: vec![] }
|
||||
);
|
||||
assert!(s.execute("object.envelope.resetWithMesh", &json!({"rows": 0})).is_err());
|
||||
// One undo step per reset.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Editing envelopes on the canvas: Edit Contents hit-testing, mesh points and handles.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
||||
use vectorcraft_doc::live::{self, EnvelopeMap};
|
||||
use vectorcraft_doc::{EnvelopeKind, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A 200×100 rectangle at (100, 100) enveloped by `cmd` → (rectangle, envelope).
|
||||
fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
|
||||
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
(a, id_of(&s.execute(cmd, ¶ms).unwrap()))
|
||||
}
|
||||
|
||||
fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec<Point>, Vec<[Vec2; 4]>) {
|
||||
match node(s, e).kind {
|
||||
NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_contents_clicks_reach_the_content_where_it_is() {
|
||||
let mut s = session();
|
||||
let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50}));
|
||||
let doc = |s: &Session| s.doc().unwrap().doc.clone();
|
||||
// Inside the rectangle near its bottom middle, which the arch lifted away.
|
||||
let below = Point::new(200.0, 195.0);
|
||||
let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap();
|
||||
assert_eq!((h.leaf, h.top_object(None), h.contents_of), (e, e, None), "the envelope is one object");
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap();
|
||||
assert_eq!((h.leaf, h.top_object(None), h.contents_of), (a, a, Some(e)), "Edit Contents: the content is the object clicked");
|
||||
// The Selection tool picks it.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("selection", ViewInfo::default()).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 200.0, 195.0), ViewInfo::default()).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_handles_start_from_the_smooth_mesh() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2}));
|
||||
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 210, "y": 130})).unwrap();
|
||||
assert!(mesh(&s, e).3.is_empty(), "moving a point keeps the smooth mesh");
|
||||
let smooth = |s: &Session, u: f64, v: f64| EnvelopeMap::of(&node(s, e)).unwrap().at(u, v);
|
||||
let before: Vec<Point> = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect();
|
||||
// Placing a handle where it already is fills in the handles: the grid lines don't move.
|
||||
let (rows, cols, points, _) = mesh(&s, e);
|
||||
let h = live::smooth_handles(rows, cols, &points)[4][live::H_RIGHT];
|
||||
let end = points[4] + h;
|
||||
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y, "handle": 0})).unwrap();
|
||||
assert_eq!(mesh(&s, e).3.len(), 9);
|
||||
let after: Vec<Point> = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect();
|
||||
for (p, q) in before.iter().zip(&after) {
|
||||
assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}");
|
||||
}
|
||||
// Now the handle bends the row line up right of the point.
|
||||
let y0 = smooth(&s, 0.62, 0.5).y;
|
||||
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y - 40.0, "handle": 0})).unwrap();
|
||||
assert!(smooth(&s, 0.62, 0.5).y < y0 - 1.0, "{} vs {y0}", smooth(&s, 0.62, 0.5).y);
|
||||
assert!(s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 0, "y": 0, "handle": 7})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_commands_edit_mesh_envelopes() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
|
||||
let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 150, "y": 175})).unwrap();
|
||||
let (rows, cols, points, handles) = mesh(&s, e);
|
||||
assert_eq!((rows, cols, handles.len()), (2, 2, 9));
|
||||
let i = r["index"].as_u64().unwrap() as usize;
|
||||
assert!(points[i].distance(Point::new(150.0, 175.0)) < 0.5);
|
||||
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 160, "y": 160})).unwrap();
|
||||
assert!(mesh(&s, e).2[i].distance(Point::new(160.0, 160.0)) < 1e-9);
|
||||
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 200, "y": 160, "handle": 0})).unwrap();
|
||||
assert!((mesh(&s, e).3[i][0] - Vec2::new(40.0, 0.0)).hypot() < 1e-9);
|
||||
s.execute("object.mesh.deletePoint", &json!({"id": e.0, "index": i})).unwrap();
|
||||
assert_eq!(mesh(&s, e).0, 1);
|
||||
assert!(s.execute("object.mesh.setPointColor", &json!({"id": e.0, "index": 0, "color": "#ff0000"})).is_err(), "envelope points have no colour");
|
||||
// One undo step per edit.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(mesh(&s, e).0, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_handles_turn_with_the_envelope_and_round_trip() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
|
||||
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 0, "x": 150, "y": 100, "handle": 0})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 90, "origin": [200, 150]})).unwrap();
|
||||
let h = mesh(&s, e).3[0][0];
|
||||
let want = Affine::rotate(-std::f64::consts::FRAC_PI_2) * Point::new(50.0, 0.0);
|
||||
assert!((h - want.to_vec2()).hypot() < 1e-9, "{h:?}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
assert_eq!(back.node(e), d.node(e));
|
||||
// Mesh envelopes saved before handles existed load as smooth meshes.
|
||||
let mut old = serde_json::to_value(node(&s, e)).unwrap();
|
||||
old["kind"]["kind"].as_object_mut().unwrap().remove("handles");
|
||||
let n: Node = serde_json::from_value(old).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() {
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 60}));
|
||||
let warp = EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
|
||||
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let (_, _, points, handles) = mesh(&s, e);
|
||||
for (q, (p, h)) in warp.points.iter().zip(points.iter().zip(&handles)) {
|
||||
assert!(q.p.distance(*p) < 1e-9 && q.handles == *h);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners.
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
|
||||
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
|
||||
//! points (a key object on a point moves with it) and, once a point is clicked, its handles.
|
||||
//! points (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`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
@@ -14,6 +15,7 @@ use vectorcraft_geom::{PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{CornerDrag, over_widget};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::select::matrix_json;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
@@ -54,6 +56,8 @@ enum State {
|
||||
anchor: usize,
|
||||
out: bool,
|
||||
},
|
||||
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
||||
Mesh,
|
||||
}
|
||||
|
||||
pub struct DirectSelectionTool {
|
||||
@@ -61,11 +65,12 @@ pub struct DirectSelectionTool {
|
||||
state: State,
|
||||
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
|
||||
spine: Option<(NodeId, usize)>,
|
||||
mesh: MeshEdit,
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
pub fn new(group: bool) -> Self {
|
||||
Self { group, state: State::Idle, spine: None }
|
||||
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +209,11 @@ impl Tool for DirectSelectionTool {
|
||||
return vec![Action::Begin("Reshape Spine".into())];
|
||||
}
|
||||
self.spine = None;
|
||||
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
|
||||
self.state = State::Mesh;
|
||||
return self.mesh.press(g);
|
||||
}
|
||||
self.mesh.unfocus();
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
|
||||
self.state = State::Handle { id, si, ai, out };
|
||||
return vec![Action::Begin("Reshape".into())];
|
||||
@@ -290,6 +300,11 @@ impl Tool for DirectSelectionTool {
|
||||
"object.blend.spine.moveAnchor".into(),
|
||||
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
|
||||
)],
|
||||
(PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(),
|
||||
(PointerKind::Up, State::Mesh) => {
|
||||
self.state = State::Idle;
|
||||
self.mesh.release().unwrap_or_default()
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, add };
|
||||
vec![]
|
||||
@@ -338,7 +353,11 @@ impl Tool for DirectSelectionTool {
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
State::Corner(c) => c.overlays(cx),
|
||||
_ => self.spine_overlays(cx),
|
||||
_ => {
|
||||
let mut out = self.spine_overlays(cx);
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod draw2;
|
||||
pub mod extra;
|
||||
pub mod guides;
|
||||
pub mod meshblend;
|
||||
pub mod meshedit;
|
||||
pub mod params;
|
||||
pub mod pen;
|
||||
pub mod place;
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
//! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens
|
||||
//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over.
|
||||
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
|
||||
//! the click); click inside a mesh → `object.mesh.addLine` (new point takes the current fill
|
||||
//! colour unless Shift); drag a mesh point → `object.mesh.movePoint` previews; Alt-click a mesh
|
||||
//! point → `object.mesh.deletePoint`.
|
||||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||||
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
|
||||
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
|
||||
//! `object.mesh.deletePoint`.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::meshedit::{MeshEdit, grid_of};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
@@ -140,17 +142,17 @@ impl Tool for BlendTool {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MeshTool {
|
||||
drag: Option<(NodeId, usize)>,
|
||||
edit: MeshEdit,
|
||||
}
|
||||
|
||||
impl MeshTool {
|
||||
/// Meshes to consider for point hits: selected ones first, then any mesh in the document.
|
||||
/// Meshes (gradient meshes and mesh 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).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)))).collect();
|
||||
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 rest = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Mesh(_)) {
|
||||
if grid_of(n).is_some() {
|
||||
rest.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -163,12 +165,8 @@ impl MeshTool {
|
||||
v
|
||||
}
|
||||
|
||||
fn point_at(cx: &ToolContext, p: Point) -> Option<(NodeId, usize)> {
|
||||
let tol = cx.tol(5.0);
|
||||
Self::meshes(cx).into_iter().find_map(|id| match &cx.doc.node(id)?.kind {
|
||||
NodeKind::Mesh(m) => m.point_near(p, tol).map(|i| (id, i)),
|
||||
_ => None,
|
||||
})
|
||||
fn grab_at(&self, cx: &ToolContext, p: Point) -> Option<crate::meshedit::MeshGrab> {
|
||||
self.edit.hit(cx, &Self::meshes(cx), p)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,13 +178,14 @@ impl Tool for MeshTool {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
if let Some((id, index)) = Self::point_at(cx, p) {
|
||||
if ev.mods.alt {
|
||||
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": id.0, "index": index}))];
|
||||
if let Some(g) = self.grab_at(cx, p) {
|
||||
if ev.mods.alt && g.handle.is_none() {
|
||||
self.edit.unfocus();
|
||||
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
|
||||
}
|
||||
self.drag = Some((id, index));
|
||||
return vec![Action::Begin("Move Mesh Point".into())];
|
||||
return self.edit.press(g);
|
||||
}
|
||||
self.edit.unfocus();
|
||||
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
|
||||
let color = match (&cx.paint.fill, ev.mods.shift) {
|
||||
(Paint::Solid { color, .. }, false) => Some(color.to_hex()),
|
||||
@@ -201,6 +200,9 @@ impl Tool for MeshTool {
|
||||
}
|
||||
return vec![Action::Exec("object.mesh.addLine".into(), params)];
|
||||
}
|
||||
Some(NodeKind::Envelope { .. }) if cx.doc.node(id).and_then(grid_of).is_some() => {
|
||||
return vec![Action::Exec("object.mesh.addLine".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
|
||||
}
|
||||
Some(NodeKind::Path { guide: false, .. }) | Some(NodeKind::Compound { .. }) => {
|
||||
return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y]}))];
|
||||
}
|
||||
@@ -209,14 +211,8 @@ impl Tool for MeshTool {
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => match self.drag {
|
||||
Some((id, index)) => vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": id.0, "index": index, "x": p.x, "y": p.y}))],
|
||||
None => vec![],
|
||||
},
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(_) => vec![Action::Commit],
|
||||
None => vec![],
|
||||
},
|
||||
PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(),
|
||||
PointerKind::Up => self.edit.release().unwrap_or_default(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -230,20 +226,22 @@ impl Tool for MeshTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(self.edit.overlays(cx));
|
||||
out
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
|
||||
match Self::point_at(cx, p) {
|
||||
Some(_) if mods.alt => Cursor::PenDelete,
|
||||
match self.grab_at(cx, p) {
|
||||
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::PenAdd,
|
||||
}
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
self.edit.dragging()
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
self.edit.unfocus();
|
||||
self.edit.release().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
|
||||
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
|
||||
//! point focuses it: its handles show and can be dragged too.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
|
||||
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Action, Overlay, ToolContext};
|
||||
|
||||
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).
|
||||
pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
|
||||
match &n.kind {
|
||||
NodeKind::Mesh(m) => Some(Cow::Borrowed(m)),
|
||||
NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => {
|
||||
envelope_grid(*rows, *cols, points, handles).map(Cow::Owned)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a press on a mesh grabs: the mesh, the point and (on the focused point) a handle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MeshGrab {
|
||||
pub id: NodeId,
|
||||
pub index: usize,
|
||||
pub handle: Option<usize>,
|
||||
}
|
||||
|
||||
/// Point and handle drags on meshes.
|
||||
#[derive(Default)]
|
||||
pub struct MeshEdit {
|
||||
/// The point last clicked: its handles show and can be dragged.
|
||||
focus: Option<(NodeId, usize)>,
|
||||
drag: Option<MeshGrab>,
|
||||
}
|
||||
|
||||
impl MeshEdit {
|
||||
/// The handle of the focused point, else the mesh point, under `p` among the meshes `ids`
|
||||
/// (editable ones, in order).
|
||||
pub fn hit(&self, cx: &ToolContext, ids: &[NodeId], p: Point) -> Option<MeshGrab> {
|
||||
let tol = cx.tol(5.0);
|
||||
if let Some((id, index)) = self.focus
|
||||
&& ids.contains(&id)
|
||||
&& let Some(grid) = cx.doc.node(id).and_then(grid_of)
|
||||
&& let Some(q) = grid.points.get(index)
|
||||
&& let Some(h) = (0..4).find(|h| q.handles[*h].hypot() > 1e-6 && (q.p + q.handles[*h]).distance(p) <= tol)
|
||||
{
|
||||
return Some(MeshGrab { id, index, handle: Some(h) });
|
||||
}
|
||||
ids.iter().filter(|id| cx.doc.is_editable(**id)).find_map(|id| {
|
||||
let index = grid_of(cx.doc.node(*id)?)?.point_near(p, tol)?;
|
||||
Some(MeshGrab { id: *id, index, handle: None })
|
||||
})
|
||||
}
|
||||
|
||||
/// Start dragging `grab` (its point takes the focus).
|
||||
pub fn press(&mut self, grab: MeshGrab) -> Vec<Action> {
|
||||
self.focus = Some((grab.id, grab.index));
|
||||
self.drag = Some(grab);
|
||||
vec![Action::Begin("Move Mesh Point".into())]
|
||||
}
|
||||
|
||||
/// The preview of a drag to `p` (`None` when nothing is being dragged).
|
||||
pub fn drag_to(&self, p: Point) -> Option<Vec<Action>> {
|
||||
let g = self.drag?;
|
||||
let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});
|
||||
if let Some(h) = g.handle {
|
||||
params["handle"] = json!(h);
|
||||
}
|
||||
Some(vec![Action::Preview("object.mesh.movePoint".into(), params)])
|
||||
}
|
||||
|
||||
/// End a drag (`None` when nothing was being dragged).
|
||||
pub fn release(&mut self) -> Option<Vec<Action>> {
|
||||
self.drag.take().map(|_| vec![Action::Commit])
|
||||
}
|
||||
|
||||
pub fn dragging(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
/// Forget the focused point.
|
||||
pub fn unfocus(&mut self) {
|
||||
self.focus = None;
|
||||
}
|
||||
|
||||
/// The focused point's handles (lines and ends).
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some((id, index)) = self.focus else { return vec![] };
|
||||
let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return vec![] };
|
||||
let Some(q) = grid.points.get(index) else { return vec![] };
|
||||
let mut out = vec![Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 }];
|
||||
for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) {
|
||||
out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });
|
||||
out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{PointerEvent, PointerKind, Tool};
|
||||
use vectorcraft_doc::live::{EnvelopeOptions, grid_points};
|
||||
use vectorcraft_doc::{Document, Selection};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
/// The rectangle of [`doc_with_rect`] (100..200 square) in a 1×1 mesh envelope.
|
||||
fn doc_with_envelope() -> (Document, NodeId) {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
let rect = d.node(a).cloned().unwrap();
|
||||
d.remove(a).unwrap();
|
||||
let id = d.alloc_id();
|
||||
let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] };
|
||||
let env = NodeKind::Envelope {
|
||||
content: vec![Arc::new(rect)],
|
||||
kind,
|
||||
fidelity: 50.0,
|
||||
editing: false,
|
||||
options: EnvelopeOptions::NEW,
|
||||
frame: Affine::IDENTITY,
|
||||
};
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, Node::new(id, env)).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mesh_tool_drags_envelope_points_and_adds_lines() {
|
||||
let (d, e) = doc_with_envelope();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = crate::meshblend::create("mesh").unwrap();
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())]);
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0)),
|
||||
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 1, "x": 210.0, "y": 90.0}))]
|
||||
);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 90.0)), vec![Action::Commit]);
|
||||
// The clicked point shows its handles (the smooth mesh's) and they can be dragged.
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Handle { .. })));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 133.33333)), vec![Action::Begin("Move Mesh Point".into())]);
|
||||
let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 140.0));
|
||||
assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(2)), "{drag:?}");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 140.0));
|
||||
// Inside the envelope: a new row and column.
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)),
|
||||
vec![Action::Exec("object.mesh.addLine".into(), json!({"id": e.0, "x": 150.0, "y": 160.0}))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_drags_a_selected_envelopes_points() {
|
||||
let (d, e) = doc_with_envelope();
|
||||
let mut s = Selection::default();
|
||||
let p = paint();
|
||||
let mut t = crate::direct::DirectSelectionTool::new(false);
|
||||
// Not selected: its points aren't handles to grab.
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 200.0));
|
||||
s.set([e]);
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 210.0)),
|
||||
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 2, "x": 90.0, "y": 210.0}))]
|
||||
);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);
|
||||
}
|
||||
}
|
||||
@@ -785,18 +785,36 @@ fn c32(rgb: [u8; 3]) -> Color32 {
|
||||
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
|
||||
fn node_outline(n: &Node) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
n.walk(&mut |c| match &c.kind {
|
||||
walk_drawn(n, &mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
if let Some(b) = c.geometric_bounds() {
|
||||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||||
}
|
||||
}
|
||||
// An envelope shows its mesh (or top object), not its content.
|
||||
NodeKind::Envelope { .. } => {
|
||||
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
bp.extend(lines.to_bezpath());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
bp
|
||||
}
|
||||
|
||||
/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the
|
||||
/// envelope shows its mesh instead).
|
||||
fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if matches!(n.kind, NodeKind::Envelope { .. }) {
|
||||
return;
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk_drawn(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The topmost editable object under document point `p` at `zoom` (3 px tolerance).
|
||||
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
|
||||
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false };
|
||||
@@ -1039,8 +1057,14 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let partial = st.selection.partial(*id);
|
||||
// Path outlines.
|
||||
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
|
||||
// Anchors (and handles for selected anchors in direct mode).
|
||||
n.walk(&mut |c| {
|
||||
// Anchors (and handles for selected anchors in direct mode); a mesh envelope's points.
|
||||
walk_drawn(n, &mut |c| {
|
||||
if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
for q in &grid.points {
|
||||
anchor_square(p, xf.to_screen(q.p), color, false, if direct { 5.0 } else { 4.0 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||||
for (si, ai, a) in path.anchors() {
|
||||
let sel = match partial {
|
||||
|
||||
@@ -138,6 +138,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_space(6.0);
|
||||
crate::place::control_bar_details(app, ui);
|
||||
crate::toolbar::control_bar_options(app, ui);
|
||||
crate::dialogs::envelope::control_bar(app, ui);
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
|
||||
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100),
|
||||
//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`.
|
||||
//!
|
||||
//! The Control bar's envelope controls ([`control_bar`]) live here too.
|
||||
//!
|
||||
//! Every dialog also has `preview`. They open with the selected envelope's values
|
||||
//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh
|
||||
//! open the Reset variant while an envelope is selected, as the reference app's do.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{EnvelopeKind, NodeKind};
|
||||
use vectorcraft_render::effects::WARP_STYLES;
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
@@ -221,6 +224,97 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
form::commit_preview(app, cmd, p)
|
||||
}
|
||||
|
||||
/// What the Control bar shows for the selected envelope.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Bar {
|
||||
Warp { style: String, bend: f64, h: f64, v: f64, horizontal: bool },
|
||||
Mesh { rows: u32, cols: u32 },
|
||||
TopObject,
|
||||
}
|
||||
|
||||
/// The selected envelope (or the one whose contents are selected), whether its contents are being
|
||||
/// edited, and its settings.
|
||||
fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
|
||||
let st = app.session.active()?;
|
||||
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 } => {
|
||||
Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal }
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols },
|
||||
EnvelopeKind::TopObject { .. } => Bar::TopObject,
|
||||
};
|
||||
Some((*editing, bar))
|
||||
}
|
||||
|
||||
/// The Control bar's envelope controls (as in the reference app) while an envelope, or content
|
||||
/// whose envelope is being edited, is selected: Edit Envelope / Edit Contents; a warp's style,
|
||||
/// orientation, bend and distortions, or a mesh's rows and columns; Reset Envelope Shape; and
|
||||
/// Envelope Options.
|
||||
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let Some((editing, bar)) = bar_of(app) else { return };
|
||||
let mut run: Option<(&str, Value)> = None;
|
||||
ui.separator();
|
||||
for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] {
|
||||
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
|
||||
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let field = |ui: &mut egui::Ui, key: &str, label: &str, value: f64, suffix: &str| {
|
||||
widgets::dim_label(ui, label);
|
||||
widgets::plain_field(ui, ("cb-envelope", key), value, suffix, 0, 46.0)
|
||||
};
|
||||
match &bar {
|
||||
Bar::Warp { style, bend, h, v, horizontal } => {
|
||||
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
|
||||
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
|
||||
widgets::dim_label(ui, "Style:");
|
||||
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
|
||||
run = Some((OPTIONS_CMD, json!({ "style": id })));
|
||||
}
|
||||
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
|
||||
if widgets::radio(ui, label, *horizontal == h, true) {
|
||||
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
|
||||
}
|
||||
}
|
||||
for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] {
|
||||
if let Some(n) = field(ui, key, label, value, "%") {
|
||||
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
|
||||
}
|
||||
}
|
||||
}
|
||||
Bar::Mesh { rows, cols } => {
|
||||
for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] {
|
||||
if let Some(n) = field(ui, key, label, f64::from(value), "") {
|
||||
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
|
||||
}
|
||||
}
|
||||
}
|
||||
Bar::TopObject => {}
|
||||
}
|
||||
// Reset Envelope Shape: an unbent warp, a flat mesh.
|
||||
let reset = match bar {
|
||||
Bar::Warp { .. } => Some((OPTIONS_CMD, json!({"bend": 0, "h": 0, "v": 0}))),
|
||||
Bar::Mesh { .. } => Some((RESET_MESH, json!({"maintainShape": false}))),
|
||||
Bar::TopObject => None,
|
||||
};
|
||||
if let Some(r) = reset
|
||||
&& widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked()
|
||||
{
|
||||
run = Some(r);
|
||||
}
|
||||
if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
|
||||
}
|
||||
if let Some((cmd, p)) = run
|
||||
&& let Err(e) = app.run(cmd, p)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -343,4 +437,55 @@ mod tests {
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_contents_reads_edit_envelope_while_editing() {
|
||||
let (mut app, _) = app_with_rect();
|
||||
app.run(MAKE_WARP, json!({})).unwrap();
|
||||
let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "object.envelope.editContents", "Edit Contents");
|
||||
assert_eq!(label(&app), "Edit Contents");
|
||||
app.run("object.envelope.editContents", json!({})).unwrap();
|
||||
assert_eq!(label(&app), "Edit Envelope");
|
||||
assert!(crate::menus::shortcut_of("object.envelope.editContents").is_none(), "its reference shortcut is Paste in Place's here");
|
||||
}
|
||||
|
||||
/// One Control bar frame with `events`; the rectangles of its 24 pt icon buttons, left to right.
|
||||
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1800.0, 600.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut r: Vec<egui::Rect> = ctx.viewport(|vp| {
|
||||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == egui::vec2(24.0, 24.0)).map(|w| w.rect).collect()
|
||||
});
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_bar_switches_to_edit_contents_and_back() {
|
||||
let (mut app, rect) = app_with_rect();
|
||||
app.run(MAKE_WARP, json!({"style": "arch"})).unwrap();
|
||||
assert!(bar_of(&app).is_some_and(|(editing, bar)| !editing && matches!(bar, Bar::Warp { ref style, .. } if style == "arch")));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let click = |app: &mut VectorcraftApp, at: egui::Pos2| {
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
bar_frame(app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
bar_frame(app, &ctx, vec![button(false)]);
|
||||
};
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
let buttons = bar_frame(&mut app, &ctx, vec![]);
|
||||
// Edit Envelope, Edit Contents come first.
|
||||
click(&mut app, buttons[1].center());
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects, vec![rect], "Edit Contents selects the content");
|
||||
assert!(bar_of(&app).is_some_and(|(editing, _)| editing), "the content keeps the envelope's controls");
|
||||
let buttons = bar_frame(&mut app, &ctx, vec![]);
|
||||
click(&mut app, buttons[0].center());
|
||||
assert!(bar_of(&app).is_some_and(|(editing, _)| !editing));
|
||||
// A mesh envelope shows its rows and columns instead.
|
||||
app.run(RESET_MESH, json!({"rows": 3, "cols": 2})).unwrap();
|
||||
assert_eq!(bar_of(&app).map(|b| b.1), Some(Bar::Mesh { rows: 3, cols: 2 }));
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
}
|
||||
}
|
||||
@@ -1194,6 +1194,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id)
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
// Edit Contents reads Edit Envelope while an envelope's contents are being edited.
|
||||
"object.envelope.editContents" => {
|
||||
let editing = app.session.active().is_some_and(|st| {
|
||||
st.selection
|
||||
.objects
|
||||
.first()
|
||||
.and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))
|
||||
.is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. }))
|
||||
});
|
||||
if editing { "Edit Envelope" } else { "Edit Contents" }.into()
|
||||
}
|
||||
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
|
||||
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
|
||||
|
||||
@@ -526,3 +526,9 @@ Perspective grid widgets: the Plane Switching Widget is fixed in a corner of the
|
||||
the grid shows. `ui.key` with `key` `"1"`…`"4"` picks the left, horizontal, right or no plane. Double-clicking the
|
||||
Perspective Grid tool (`tool.options {tool: "perspectiveGrid"}`) opens `perspectiveGridOptions` (fields `show`,
|
||||
`position`: `topLeft`, `topRight`, `bottomLeft`, `bottomRight`); `ui.dialog.confirm` runs `perspective.widget.options`.
|
||||
|
||||
Envelopes on the canvas: `pointer_gesture` with the Mesh tool (`mesh`) or Direct Selection on a selected mesh envelope
|
||||
drags its points (a press on a point focuses it, and a press on one of the focused point's handle ends drags that
|
||||
handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is
|
||||
selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and
|
||||
distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command.
|
||||
+19
-1
@@ -1960,7 +1960,6 @@ sits on; the first edit turns the spine into a path. Moving a key moves its end
|
||||
{"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}}
|
||||
```
|
||||
|
||||
|
||||
How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes
|
||||
interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale
|
||||
and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with
|
||||
@@ -1971,3 +1970,22 @@ symbol switch halfway). New blends are knockout groups (`object.setProps {knocko
|
||||
canvas as they export. `object.blend.expand` and `object.blend.release` keep the blend's name, transparency,
|
||||
opacity mask and appearance (Release on a group around the keys and spine when the blend has any), and
|
||||
`object.expand` with `object: true` expands the blends in the selection.
|
||||
|
||||
## Editing envelopes
|
||||
|
||||
`object.envelope.editContents {editing}` switches between editing the envelope and its contents (the menu item reads
|
||||
Edit Envelope while the contents are edited; it has no default shortcut, since the reference app's is Paste in Place
|
||||
here). While the contents are edited, clicks hit the content where it sits undistorted and the Selection tool selects
|
||||
it. A mesh envelope's points have bezier handles (`kind.handles`, offsets
|
||||
right/left/down/up; none stored means the smooth mesh through the points, as older files have):
|
||||
`object.envelope.setMeshPoint {id, index, x, y, handle?}` and `object.mesh.movePoint`, `object.mesh.addLine` and
|
||||
`object.mesh.deletePoint` edit them like a gradient mesh's (one undo step each). On the canvas a selected envelope
|
||||
shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool
|
||||
adds a row and a column where it clicks inside. 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.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.envelope.editContents","params":{"editing":true}}}
|
||||
{"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}}
|
||||
```
|
||||
Reference in new issue
Block a user