diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index dddc9d41..1c46747f 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -20,15 +20,18 @@ pub struct Hit { pub leaf: NodeId, pub ancestry: Vec, 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, } 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 { - 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| 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 { 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 { 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 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; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index d31bafc0..4f20e8f7 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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 }, + Mesh { + rows: u32, + cols: u32, + points: Vec, + /// 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 { - 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 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, p: Point, next: Option| 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 { + // (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)> { + 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], 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], kind: &EnvelopeKind, frame: Affine) -> Option { 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))) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 27b5ba84..10869b89 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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 { .. } => {} diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index dd490ac5..baa0ffb7 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -218,7 +218,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 { "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 { /// 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 { @@ -969,7 +973,7 @@ fn env_make_mesh(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let nodes: Vec> = 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 { 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 { 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 { Ok(r) } -fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { +/// 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(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { 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> { + 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 { 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 { 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 { 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 { 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 { fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { 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() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc6692fd..8095cbf2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs index 0166ac6a..6e2dda8c 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests_envelope.rs @@ -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(); diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests_envelope_edit.rs new file mode 100644 index 00000000..1cccb9b8 --- /dev/null +++ b/crates/engine/src/tests_envelope_edit.rs @@ -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, 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 = (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 = (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); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index b1bc0c26..998d4aea 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -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 { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9cf2fb06..3bbc8a01 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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; diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 7670f31e..5958b550 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -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 { - let mut v: Vec = - 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 = 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 { + 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 { - if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + self.edit.unfocus(); + self.edit.release().unwrap_or_default() } } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs new file mode 100644 index 00000000..3a6a53fe --- /dev/null +++ b/crates/tools/src/meshedit.rs @@ -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> { + 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, +} + +/// 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, +} + +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 { + 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 { + 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> { + 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> { + 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 { + 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]); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5be03d7a..ca328b03 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { 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 { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dd48603e..680ce95a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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); diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 24b62774..06516902 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -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 { 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) -> Vec { + 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 = 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![]); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b00800ce..c880f684 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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) => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ace70e7f..ac4f3277 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index cd0c05d3..a7d7d456 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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}}} +```