mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 08:56:08 +00:00
A selected warp envelope now shows the points of its surface on a 4x4 grid (8x8 or 16x16 where 4x4 strays more than 0.1% of the warp's diagonal from it). object.mesh.movePoint, movePoints, addLine, deletePoint and object.envelope.setMeshPoint accept a warp: the first edit turns it into a mesh envelope on that grid and applies the edit as one undo step, so Undo or Escape gives the warp back with its style and bend. An edit that moves no point leaves the warp and adds no undo step. A warp that no such grid follows (some version 0 maps) shows no points and its edits fail without changing anything. movePoints and setPointColor on a gradient mesh record no undo step when they change no point.
647 lines
32 KiB
Rust
647 lines
32 KiB
Rust
//! Blend tool (W) and Mesh tool (U).
|
||
//!
|
||
//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor
|
||
//! point of a path blends from that point (`starts`). Each further click adds the object as one
|
||
//! 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 or pressing Escape starts over.
|
||
//! A press on a widget of the selected blend drags it ([`crate::blendwidget`]).
|
||
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
|
||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||
//! new point takes the current fill color in its own model, unless Shift); drag a mesh point,
|
||
//! or a handle of the point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]);
|
||
//! Shift-click mesh points to pick several (Shift-click a picked one to drop it): a drag of one
|
||
//! moves them all (`object.mesh.movePoints`) and the arrow keys nudge them. Alt-click a mesh
|
||
//! point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the
|
||
//! next solid fill color or Transparency panel opacity goes to just the picked points. Escape
|
||
//! during a drag cancels it.
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_color::Paint;
|
||
use vectorcraft_doc::{NodeId, NodeKind};
|
||
use vectorcraft_geom::Point;
|
||
|
||
use crate::blendwidget::{self, BlendWidgetDrag};
|
||
use crate::meshedit::{MeshEdit, grid_of, may_have_grid};
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||
|
||
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||
|
||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||
match id {
|
||
"blend" => Some(Box::new(BlendTool::default())),
|
||
"mesh" => Some(Box::new(MeshTool::default())),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn hit_top(cx: &ToolContext, p: Point) -> Option<(NodeId, NodeId)> {
|
||
let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?;
|
||
Some((h.top_object(cx.isolation), h.leaf))
|
||
}
|
||
|
||
/// The dialog the Blend tool's double-click, Alt-click and toolbar button open.
|
||
const BLEND_OPTIONS: &str = "blendOptions";
|
||
|
||
/// What the Blend tool picks: an object and the anchor of its first subpath clicked, if any.
|
||
type Pick = (NodeId, Option<usize>);
|
||
|
||
#[derive(Default)]
|
||
pub struct BlendTool {
|
||
first: Option<Pick>,
|
||
/// A blend was just made: the next click adds to it (the selected blend).
|
||
chained: bool,
|
||
/// `first` and `chained` when the button went down: a dropped press puts them back, unless
|
||
/// the press picked an object or made a blend.
|
||
before_press: Option<(Option<Pick>, bool)>,
|
||
/// A press on a widget of the selected blend, and its drag.
|
||
widget: Option<BlendWidgetDrag>,
|
||
}
|
||
|
||
/// The anchor of the first subpath of path or compound path `id` (its first member's) nearest
|
||
/// `p`, within the click tolerance.
|
||
fn anchor_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<usize> {
|
||
let n = cx.doc.node(id)?;
|
||
let path = match &n.kind {
|
||
NodeKind::Path { path, .. } => path,
|
||
NodeKind::Compound { children, .. } => children.first()?.path_data()?,
|
||
_ => return None,
|
||
};
|
||
let tol = cx.tol(5.0);
|
||
let d = |a: &vectorcraft_geom::Anchor| a.p.distance(p);
|
||
let (i, a) = path.subpaths.first()?.anchors.iter().enumerate().min_by(|x, y| d(x.1).total_cmp(&d(y.1)))?;
|
||
(d(a) <= tol).then_some(i)
|
||
}
|
||
|
||
/// The object the Blend tool picks at `p` (the top object hit) and the anchor clicked.
|
||
fn pick(cx: &ToolContext, p: Point) -> Option<Pick> {
|
||
let (top, _) = hit_top(cx, p)?;
|
||
Some((top, anchor_at(cx, top, p)))
|
||
}
|
||
|
||
impl BlendTool {
|
||
/// The object picked first: the clicked one, or the blend just made.
|
||
fn first(&self, cx: &ToolContext) -> Option<Pick> {
|
||
self.first.or_else(|| match cx.selection.objects.as_slice() {
|
||
[b] if self.chained && cx.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })) => Some((*b, None)),
|
||
_ => None,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Tool for BlendTool {
|
||
fn id(&self) -> &'static str {
|
||
"blend"
|
||
}
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
let options = |t: &mut Self| {
|
||
(t.first, t.chained, t.before_press) = (None, false, None);
|
||
vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))]
|
||
};
|
||
// A first pick whose object is no longer in the document (deleted, or undone away) is
|
||
// dropped, so the click picks the object it hits as the new first pick.
|
||
if self.first.is_some_and(|(a, _)| cx.doc.node(a).is_none()) {
|
||
(self.first, self.chained) = (None, false);
|
||
}
|
||
// The selected blend's widgets take a press before the picks, and its drag.
|
||
match (ev.kind, &mut self.widget) {
|
||
(PointerKind::Drag, Some(d)) => return d.drag(cx, ev.pos),
|
||
(PointerKind::Up, Some(_)) => return self.widget.take().map(BlendWidgetDrag::finish).unwrap_or_default(),
|
||
(PointerKind::DoubleClick, _) if blendwidget::over_widget(cx, ev.pos) => return vec![],
|
||
(PointerKind::Down, _) if !ev.mods.alt => {
|
||
if let Some(d) = BlendWidgetDrag::hit(cx, ev) {
|
||
self.widget = Some(d);
|
||
return vec![];
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
match ev.kind {
|
||
PointerKind::DoubleClick => options(self),
|
||
PointerKind::Down if ev.mods.alt => options(self),
|
||
PointerKind::Down => {
|
||
self.before_press = Some((self.first, self.chained));
|
||
match (self.first(cx), pick(cx, ev.pos)) {
|
||
(_, None) => {
|
||
(self.first, self.chained) = (None, false);
|
||
vec![]
|
||
}
|
||
(None, Some((top, start))) => {
|
||
(self.first, self.before_press) = (Some((top, start)), None);
|
||
vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))]
|
||
}
|
||
(Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => {
|
||
(self.first, self.chained, self.before_press) = (None, true, None);
|
||
let mut p = json!({"ids": [a.0, b.0]});
|
||
if sa.is_some() || sb.is_some() {
|
||
p["starts"] = json!([sa, sb]);
|
||
}
|
||
vec![Action::Exec("object.blend.make".into(), p)]
|
||
}
|
||
(Some(_), Some(_)) => vec![],
|
||
}
|
||
}
|
||
PointerKind::Up => {
|
||
self.before_press = None;
|
||
vec![]
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
if let Some(d) = &self.widget {
|
||
return d.overlays(cx);
|
||
}
|
||
let Some((id, start)) = self.first(cx) else { return vec![] };
|
||
let Some(n) = cx.doc.node(id) else { return vec![] };
|
||
let at = match (start, &n.kind) {
|
||
(Some(i), NodeKind::Path { path, .. }) => path.subpaths.first().and_then(|s| s.anchors.get(i)).map(|a| a.p),
|
||
(Some(i), NodeKind::Compound { children, .. }) => {
|
||
children.first().and_then(|c| c.path_data()).and_then(|p| p.subpaths.first()).and_then(|s| s.anchors.get(i)).map(|a| a.p)
|
||
}
|
||
_ => None,
|
||
};
|
||
let Some(p) = at.or_else(|| n.geometric_bounds().map(|b| b.center())) else { return vec![] };
|
||
vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }]
|
||
}
|
||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||
if self.widget.is_some() || blendwidget::over_widget(cx, p) {
|
||
return Cursor::ArrowHollow;
|
||
}
|
||
match pick(cx, p) {
|
||
Some((_, Some(_))) => Cursor::BlendAnchor,
|
||
Some(_) => Cursor::BlendObject,
|
||
None => Cursor::Blend,
|
||
}
|
||
}
|
||
fn busy(&self) -> bool {
|
||
self.first.is_some() || self.widget.is_some()
|
||
}
|
||
fn transforming(&self) -> bool {
|
||
self.widget.as_ref().is_some_and(BlendWidgetDrag::adjusting)
|
||
}
|
||
/// The arrows while a blend widget is pressed: its value.
|
||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||
self.widget.is_some() && matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right)
|
||
}
|
||
/// A first pick, or adding to the blend just made: Escape ends either.
|
||
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
|
||
self.first(cx).is_some()
|
||
}
|
||
/// Escape drops the object picked first and stops adding to the blend just made, so the next
|
||
/// click picks a new first object. The document and the selection stay as they are. While a
|
||
/// blend widget is pressed, the arrows change its value and Escape cancels its drag instead.
|
||
fn key(&mut self, cx: &ToolContext, key: crate::ToolKey, mods: Mods) -> Vec<Action> {
|
||
if let Some(d) = &mut self.widget {
|
||
return match key {
|
||
ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right => d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift),
|
||
ToolKey::Escape => {
|
||
let began = d.adjusting();
|
||
self.widget = None;
|
||
if began { vec![Action::Cancel] } else { vec![] }
|
||
}
|
||
_ => vec![],
|
||
};
|
||
}
|
||
if key == crate::ToolKey::Escape {
|
||
(self.first, self.chained, self.before_press) = (None, false, None);
|
||
}
|
||
vec![]
|
||
}
|
||
/// A dropped press puts back the object picked first and the blend being added to as they
|
||
/// were when the button went down, so the picks of earlier clicks stay. An object the press
|
||
/// picked stays picked and selected, and a blend the press made stays made.
|
||
fn drop_press(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||
if let Some((first, chained)) = self.before_press.take() {
|
||
(self.first, self.chained) = (first, chained);
|
||
}
|
||
// A dropped widget drag puts its value back.
|
||
match self.widget.take() {
|
||
Some(d) if d.adjusting() => vec![Action::Cancel],
|
||
_ => vec![],
|
||
}
|
||
}
|
||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||
(self.first, self.chained, self.before_press) = (None, false, None);
|
||
match self.widget.take() {
|
||
Some(d) if d.adjusting() => vec![Action::Cancel],
|
||
_ => vec![],
|
||
}
|
||
}
|
||
/// Choosing the Blend tool again finishes the blend: it drops the object picked first and
|
||
/// stops adding to the blend just made, so the next click picks a new first object. The
|
||
/// document and the selection stay as they are.
|
||
fn chosen_again(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||
self.deactivate(cx)
|
||
}
|
||
/// An edit, Undo or Redo made outside the tool drops the object picked first and stops adding to
|
||
/// the blend just made, so the next click picks a new first object.
|
||
fn history_moved(&mut self) {
|
||
(self.first, self.chained) = (None, false);
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct MeshTool {
|
||
edit: MeshEdit,
|
||
}
|
||
|
||
impl MeshTool {
|
||
/// Meshes (gradient meshes, and mesh and warp envelopes) to consider for point hits: selected
|
||
/// ones first, then any in the document.
|
||
fn meshes(cx: &ToolContext) -> Vec<NodeId> {
|
||
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(may_have_grid)).collect();
|
||
let mut rest = vec![];
|
||
cx.doc.walk(|n| {
|
||
if may_have_grid(n) {
|
||
rest.push(n.id);
|
||
}
|
||
});
|
||
for id in rest.into_iter().rev() {
|
||
if !v.contains(&id) {
|
||
v.push(id);
|
||
}
|
||
}
|
||
let editable = cx.doc.editable();
|
||
v.retain(|id| editable(*id));
|
||
v
|
||
}
|
||
|
||
fn grab_at(&self, cx: &ToolContext, p: Point) -> Option<crate::meshedit::MeshGrab> {
|
||
self.edit.hit(cx, &Self::meshes(cx), p)
|
||
}
|
||
|
||
/// An arrow key's nudge of the picked points.
|
||
fn nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<Vec<Action>> {
|
||
self.edit.nudge(cx, key, mods, &|_| true)
|
||
}
|
||
}
|
||
|
||
impl Tool for MeshTool {
|
||
fn id(&self) -> &'static str {
|
||
"mesh"
|
||
}
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
let p = ev.pos;
|
||
match ev.kind {
|
||
PointerKind::Down => {
|
||
let meshes = Self::meshes(cx);
|
||
if let Some(g) = self.edit.hit(cx, &meshes, 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}))];
|
||
}
|
||
// A gradient mesh's point is picked for the next fill colour, so its mesh is
|
||
// what gets selected (with Shift, added to the selection).
|
||
let mut out = vec![];
|
||
if cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
|
||
if ev.mods.shift && !cx.selection.contains(g.id) {
|
||
out.push(Action::Exec("select.add".into(), json!({"ids": [g.id.0]})));
|
||
} else if !ev.mods.shift && cx.selection.objects != [g.id] {
|
||
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
|
||
}
|
||
}
|
||
out.extend(self.edit.press(cx, &meshes, g, p, ev.mods.shift));
|
||
return out;
|
||
}
|
||
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(crate::params::color_json(color)),
|
||
_ => None,
|
||
};
|
||
// A click that hits an edge from just outside adds the lines at the edge.
|
||
let tol = cx.hit_options().tol;
|
||
for id in [leaf, top] {
|
||
match cx.doc.node(id).map(|n| &n.kind) {
|
||
Some(NodeKind::Mesh(_)) => {
|
||
let mut params = json!({"id": id.0, "x": p.x, "y": p.y, "tolerance": tol});
|
||
if let Some(c) = &color {
|
||
params["color"] = c.clone();
|
||
params["keepModel"] = json!(true);
|
||
}
|
||
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, "tolerance": tol}))];
|
||
}
|
||
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], "tolerance": tol}))];
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
vec![]
|
||
}
|
||
PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(),
|
||
PointerKind::Up => self.edit.release(cx).unwrap_or_default(),
|
||
_ => vec![],
|
||
}
|
||
}
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let mut out = vec![];
|
||
for id in &cx.selection.objects {
|
||
if let Some(NodeKind::Mesh(m)) = cx.doc.node(*id).map(|n| &n.kind) {
|
||
out.push(Overlay::Path { path: m.lines().to_bezpath(), color: FEEDBACK, width: 1.0, dashed: false });
|
||
for q in &m.points {
|
||
out.push(Overlay::Anchor { p: q.p, color: FEEDBACK, filled: false, size: 5.0 });
|
||
}
|
||
}
|
||
}
|
||
out.extend(self.edit.overlays(cx, &|_| true));
|
||
out
|
||
}
|
||
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
|
||
match self.grab_at(cx, p) {
|
||
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
|
||
Some(_) => Cursor::Move,
|
||
None => Cursor::MeshAdd,
|
||
}
|
||
}
|
||
fn options(&self) -> serde_json::Value {
|
||
self.edit.options()
|
||
}
|
||
fn busy(&self) -> bool {
|
||
self.edit.dragging()
|
||
}
|
||
/// Escape during a drag of a mesh point or handle cancels it: the point goes back and the
|
||
/// release does nothing. The arrow keys nudge the picked points.
|
||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||
if key != ToolKey::Escape {
|
||
return self.nudge(cx, key, mods).unwrap_or_default();
|
||
}
|
||
self.edit.cancel().unwrap_or_default()
|
||
}
|
||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||
self.nudge(cx, key, Mods::default()).is_some()
|
||
}
|
||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||
self.edit.unfocus();
|
||
self.edit.release(cx).unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::testutil::*;
|
||
use vectorcraft_doc::Selection;
|
||
|
||
#[test]
|
||
fn blend_tool_two_clicks_make_blend() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let b = d.alloc_id();
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = create("blend").unwrap();
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 120.0));
|
||
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
|
||
}
|
||
|
||
/// Choosing the Blend tool again drops the object picked first and stops adding to the blend
|
||
/// just made: the next click is a new first pick. The Mesh tool keeps its state.
|
||
#[test]
|
||
fn choosing_the_blend_tool_again_finishes_the_blend() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let (b, g) = (d.alloc_id(), d.alloc_id());
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (k1, k2) = (d.alloc_id(), d.alloc_id());
|
||
let key = |id: NodeId, x: f64| {
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
|
||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
|
||
};
|
||
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
|
||
d.insert(Some(l), 2, blend).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||
let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y);
|
||
let mut t = BlendTool::default();
|
||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
|
||
assert!(t.busy());
|
||
assert!(t.chosen_again(&c).is_empty(), "choosing the tool again changes nothing in the document");
|
||
assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "and drops the pick");
|
||
assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b), "b is picked first");
|
||
// After a blend is made, the next click no longer adds to it.
|
||
let mut s = Selection::default();
|
||
s.set([g]);
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
|
||
assert!(t.first(&c).is_some());
|
||
assert!(t.chosen_again(&c).is_empty());
|
||
assert!(t.first(&c).is_none(), "choosing the tool again ends adding to the blend just made");
|
||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
|
||
// Other tools keep their state: the default does nothing.
|
||
assert!(create("mesh").unwrap().chosen_again(&c).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn resetting_blend_during_a_press_survives_its_drop() {
|
||
let (d, a) = doc_with_rect();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
for reset in 0..3 {
|
||
let mut t = BlendTool { first: Some((a, None)), chained: true, ..Default::default() };
|
||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty());
|
||
match reset {
|
||
0 => assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty()),
|
||
1 => assert!(t.chosen_again(&c).is_empty()),
|
||
_ => assert_eq!(
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, 450.0, 450.0)),
|
||
vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))],
|
||
),
|
||
}
|
||
assert!(t.drop_press(&c).is_empty());
|
||
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 450.0, 450.0)).is_empty());
|
||
assert_eq!((t.first, t.chained), (None, false));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn blend_tool_picks_again_when_its_first_pick_is_gone() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let (b, e) = (d.alloc_id(), d.alloc_id());
|
||
for (id, y) in [(b, 100.0), (e, 300.0)] {
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, y, 350.0, y + 50.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
}
|
||
let (s, p) = (Selection::default(), paint());
|
||
let mut t = BlendTool::default();
|
||
t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||
// The first pick is deleted (or undone away) while the tool waits for the second click.
|
||
d.remove(a).unwrap();
|
||
let c = cx(&d, &s, &p);
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 120.0));
|
||
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))], "the click picks b first");
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 320.0, 320.0));
|
||
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, e.0]}))]);
|
||
// After a blend is made, a click while the selected blend is gone picks the object it hits.
|
||
let mut s = Selection::default();
|
||
s.set([NodeId(9999)]);
|
||
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
|
||
let acts = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 320.0, 120.0));
|
||
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [b.0]}))]);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_tool_anchor_clicks_and_options() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let b = d.alloc_id();
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = create("blend").unwrap();
|
||
// Over an anchor, over an object, away from art.
|
||
assert_eq!(t.cursor(&c, Point::new(100.0, 100.0), Mods::default()), Cursor::BlendAnchor);
|
||
assert_eq!(t.cursor(&c, Point::new(150.0, 150.0), Mods::default()), Cursor::BlendObject);
|
||
assert_eq!(t.cursor(&c, Point::new(250.0, 400.0), Mods::default()), Cursor::Blend);
|
||
// The rectangle's first anchor is its top left, b's third its bottom right.
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 99.0));
|
||
assert!(t.busy());
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 350.0, 150.0));
|
||
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0], "starts": [0, 2]}))]);
|
||
// Alt-click and double-click open Blend Options.
|
||
let alt = Mods { alt: true, ..Default::default() };
|
||
assert_eq!(
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)),
|
||
vec![Action::Dialog("blendOptions".into(), json!({}))]
|
||
);
|
||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0)), vec![Action::Dialog("blendOptions".into(), json!({}))]);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_tool_next_click_adds_to_the_blend_just_made() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let g = d.alloc_id();
|
||
let key = |id: NodeId, x: f64| {
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
|
||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
|
||
};
|
||
let (k1, k2) = (d.alloc_id(), d.alloc_id());
|
||
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
|
||
d.insert(Some(l), 1, blend).unwrap();
|
||
let mut s = Selection::default();
|
||
let p = paint();
|
||
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
|
||
s.set([g]);
|
||
let c = cx(&d, &s, &p);
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [g.0, a.0]}))]);
|
||
// Away from art: start over.
|
||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 450.0, 450.0));
|
||
assert!(t.first(&c).is_none());
|
||
}
|
||
|
||
/// Escape drops the object picked first, and stops adding to the blend just made: the next
|
||
/// click is a new first pick, and the click after it blends with that one.
|
||
#[test]
|
||
fn escape_drops_the_blend_tools_first_pick() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let (b, g) = (d.alloc_id(), d.alloc_id());
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (k1, k2) = (d.alloc_id(), d.alloc_id());
|
||
let key = |id: NodeId, x: f64| {
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
|
||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
|
||
};
|
||
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
|
||
d.insert(Some(l), 2, blend).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||
let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y);
|
||
let esc = |t: &mut BlendTool| t.key(&c, crate::ToolKey::Escape, Mods::default());
|
||
let mut t = BlendTool::default();
|
||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
|
||
assert!(t.busy() && !t.overlays(&c).is_empty());
|
||
assert!(t.key(&c, crate::ToolKey::Enter, Mods::default()).is_empty());
|
||
assert!(t.busy(), "Enter keeps the pick");
|
||
assert!(esc(&mut t).is_empty(), "Escape changes nothing in the document");
|
||
assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "Escape drops the pick");
|
||
// The next click picks b first instead of blending a with it.
|
||
assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b));
|
||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, a.0]}))]);
|
||
// After a blend is made, Escape stops the next click adding to it.
|
||
let mut s = Selection::default();
|
||
s.set([g]);
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
|
||
assert!(t.first(&c).is_some());
|
||
assert!(!t.busy() && t.ends_on_escape(&c), "Escape has adding to the blend to end");
|
||
assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty());
|
||
assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made");
|
||
assert!(!t.ends_on_escape(&c), "and then has nothing to end");
|
||
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
|
||
assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop");
|
||
}
|
||
|
||
/// A dropped press (a long touch, a canceled touch) keeps the pick an earlier click made and
|
||
/// keeps adding to the blend just made: the next click blends with them. A dropped press
|
||
/// that picked an object keeps that pick, as the object stays selected.
|
||
#[test]
|
||
fn a_dropped_press_keeps_the_blend_tools_first_pick() {
|
||
let (mut d, a) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let (b, g) = (d.alloc_id(), d.alloc_id());
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
|
||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (k1, k2) = (d.alloc_id(), d.alloc_id());
|
||
let key = |id: NodeId, x: f64| {
|
||
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
|
||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
|
||
};
|
||
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
|
||
d.insert(Some(l), 2, blend).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y);
|
||
// The engine sends the release after the drop.
|
||
let dropped = |t: &mut BlendTool, c: &ToolContext, x: f64, y: f64| {
|
||
t.pointer(c, &ev(PointerKind::Down, x, y));
|
||
assert!(t.drop_press(c).is_empty());
|
||
assert!(t.pointer(c, &ev(PointerKind::Up, x, y)).is_empty());
|
||
};
|
||
let mut t = BlendTool::default();
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)), vec![Action::Exec("select.set".into(), json!({"ids": [a.0]}))]);
|
||
assert!(t.drop_press(&c).is_empty());
|
||
t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0));
|
||
assert_eq!(t.first(&c), Some((a, None)), "a dropped first press keeps the object it picked and selected");
|
||
// A second press on a, and a press on empty canvas, each dropped.
|
||
dropped(&mut t, &c, 151.0, 151.0);
|
||
dropped(&mut t, &c, 450.0, 450.0);
|
||
assert_eq!(t.first(&c), Some((a, None)), "the pick stays");
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 320.0, 120.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
|
||
// After a blend is made, a dropped press on empty canvas keeps adding to it.
|
||
let mut s = Selection::default();
|
||
s.set([g]);
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
|
||
dropped(&mut t, &c, 450.0, 450.0);
|
||
assert_eq!(t.first(&c), Some((g, None)), "the blend just made is still added to");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_tool_click_path_creates_mesh() {
|
||
let (d, a) = doc_with_rect();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = create("mesh").unwrap();
|
||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0));
|
||
assert_eq!(
|
||
acts,
|
||
vec![Action::Exec("object.mesh.create".into(), json!({"ids": [a.0], "at": [150.0, 160.0], "tolerance": c.hit_options().tol}))]
|
||
);
|
||
}
|
||
}
|