703 lines
34 KiB
Rust
703 lines
34 KiB
Rust
//! Width tool (Shift+W).
|
|
//!
|
|
//! Hovering a stroked path shows a hollow width-point diamond with the stroke's width at that
|
|
//! spot. Dragging outward from the path creates (or, on an existing width point's handle end,
|
|
//! edits) a width point — symmetric by default, Alt changes only the side being dragged. Dragging
|
|
//! a width point's centre slides it along the path; Alt-dragging the centre slides a copy; dropping
|
|
//! either onto another width point makes a discontinuous point (the width steps there). Shift-click
|
|
//! adds width points to the selection (or takes them out), and dragging one of several selected
|
|
//! points moves or widens them all. Double-clicking a width point opens Width Point Edit (the
|
|
//! `widthPoint` dialog). Delete/Backspace removes the selected width points.
|
|
//!
|
|
//! Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in points),
|
|
//! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids,
|
|
//! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`.
|
|
//!
|
|
//! A compound path's stroke is the compound's: its members are edited through it, and the width
|
|
//! points show on every subpath of the stroke (one profile runs along each). The cursor says what a
|
|
//! drag does: add a width point on a stroke, or move or widen the one under it.
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_doc::{Document, NodeId, NodeKind, StrokeLayer};
|
|
use vectorcraft_geom::{Point, Vec2};
|
|
|
|
use super::pathutil::{eval_fraction, left_normal, nearest_fraction};
|
|
use super::{BLUE, diamond};
|
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
|
|
|
/// Width points closer than this (in t) are at the same place (the two sides of a discontinuous
|
|
/// point).
|
|
const SAME_T: f64 = 1e-6;
|
|
|
|
/// Where the pointer is relative to a stroked path.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Spot {
|
|
/// The object whose stroke it is (a compound path for its members).
|
|
owner: NodeId,
|
|
/// The path under the pointer.
|
|
id: NodeId,
|
|
sub: usize,
|
|
/// Fraction along the subpath.
|
|
t: f64,
|
|
p: Point,
|
|
/// Left normal.
|
|
n: Vec2,
|
|
/// Side widths (points) at `t`.
|
|
left: f64,
|
|
right: f64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
enum Mode {
|
|
/// Change widths; `Some(left?)` = only one side (Alt).
|
|
Width(Option<bool>),
|
|
/// Slide along the path.
|
|
Move,
|
|
/// Slide a copy along the path (Alt-drag the centre).
|
|
Copy,
|
|
}
|
|
|
|
/// A width point as the tool shows it.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct WPoint {
|
|
index: usize,
|
|
t: f64,
|
|
/// On-path point and left normal.
|
|
p: Point,
|
|
n: Vec2,
|
|
/// Side widths (points).
|
|
left: f64,
|
|
right: f64,
|
|
}
|
|
|
|
/// Several selected width points dragged together.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct Group {
|
|
/// The profile's points when the drag began.
|
|
points: Vec<(f64, f64, f64)>,
|
|
/// Which of them move.
|
|
moving: Vec<usize>,
|
|
/// The stroke's half weight.
|
|
half: f64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct Drag {
|
|
/// Where the dragged point started (`t`, widths); `p` and `n` follow a slide.
|
|
spot: Spot,
|
|
index: Option<usize>,
|
|
mode: Mode,
|
|
left: f64,
|
|
right: f64,
|
|
t: f64,
|
|
group: Option<Group>,
|
|
/// The other width points of the subpath when the drag began, which a slid point snaps onto.
|
|
others: Vec<WPoint>,
|
|
moved: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct WidthTool {
|
|
hover: Option<Spot>,
|
|
drag: Option<Drag>,
|
|
/// Selected width points: the stroke's owner and their positions t.
|
|
selected: Option<(NodeId, Vec<f64>)>,
|
|
}
|
|
|
|
/// The object whose stroke path `id` draws with: its compound path, if it is a member of one.
|
|
pub fn stroke_owner(doc: &Document, id: NodeId) -> NodeId {
|
|
doc.parent_of(id).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id)
|
|
}
|
|
|
|
/// The visible weighted stroke of `owner`.
|
|
fn stroke_of(doc: &Document, owner: NodeId) -> Option<&StrokeLayer> {
|
|
doc.node(owner)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
|
|
}
|
|
|
|
/// The paths a stroke owner draws: itself, or a compound path's members.
|
|
fn paths_of(doc: &Document, owner: NodeId) -> Vec<NodeId> {
|
|
let mut out = vec![];
|
|
if let Some(n) = doc.node(owner) {
|
|
n.walk(&mut |c| {
|
|
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
|
|
out.push(c.id);
|
|
}
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Side widths (points) of a stroke at fraction `t`.
|
|
pub fn sides_at(st: &StrokeLayer, t: f64) -> (f64, f64) {
|
|
let (l, r) = st.profile.as_ref().map(|p| p.at(t)).unwrap_or((1.0, 1.0));
|
|
(l * st.width / 2.0, r * st.width / 2.0)
|
|
}
|
|
|
|
impl WidthTool {
|
|
/// The stroked path nearest to `p` within reach (stroke half-width + a few pixels).
|
|
fn spot_at(cx: &ToolContext, p: Point) -> Option<Spot> {
|
|
let consider = |id: NodeId| -> Option<(f64, Spot)> {
|
|
if !cx.doc.is_editable(id) {
|
|
return None;
|
|
}
|
|
let n = cx.doc.node(id)?;
|
|
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
|
|
let owner = stroke_owner(cx.doc, id);
|
|
let st = stroke_of(cx.doc, owner)?;
|
|
let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max));
|
|
let reach = widest * st.width / 2.0 + cx.tol(4.0);
|
|
if !path.bounds()?.inflate(reach, reach).contains(p) {
|
|
return None;
|
|
}
|
|
let (sub, t, q, tan, d) = nearest_fraction(path, p)?;
|
|
if d > reach {
|
|
return None;
|
|
}
|
|
let (left, right) = sides_at(st, t);
|
|
Some((d, Spot { owner, id, sub, t, p: q, n: left_normal(tan), left, right }))
|
|
};
|
|
let best_of = |ids: &mut dyn Iterator<Item = NodeId>| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0));
|
|
// The selected paths first (a selected compound path: its members).
|
|
if let Some(b) = best_of(&mut cx.selection.objects.iter().flat_map(|id| paths_of(cx.doc, *id))) {
|
|
return Some(b.1);
|
|
}
|
|
let mut all = vec![];
|
|
cx.doc.walk(|n| {
|
|
if matches!(n.kind, NodeKind::Path { .. }) {
|
|
all.push(n.id);
|
|
}
|
|
});
|
|
best_of(&mut all.into_iter().rev()).map(|b| b.1)
|
|
}
|
|
|
|
/// Width points of `owner`'s stroke on subpath `sub` of path `id`.
|
|
fn points_of(cx: &ToolContext, owner: NodeId, id: NodeId, sub: usize) -> Vec<WPoint> {
|
|
let Some(st) = stroke_of(cx.doc, owner) else { return vec![] };
|
|
let Some(pr) = &st.profile else { return vec![] };
|
|
let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] };
|
|
let Some(sp) = path.subpaths.get(sub) else { return vec![] };
|
|
pr.points
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, (t, l, r))| {
|
|
let (p, tan) = eval_fraction(sp, *t)?;
|
|
Some(WPoint { index, t: *t, p, n: left_normal(tan), left: l * st.width / 2.0, right: r * st.width / 2.0 })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Width points of `owner`'s stroke on every subpath it draws.
|
|
fn all_points(cx: &ToolContext, owner: NodeId) -> Vec<WPoint> {
|
|
let subpaths = |id: NodeId| match cx.doc.node(id).map(|n| &n.kind) {
|
|
Some(NodeKind::Path { path, .. }) => path.subpaths.len(),
|
|
_ => 0,
|
|
};
|
|
paths_of(cx.doc, owner).into_iter().flat_map(|id| (0..subpaths(id)).flat_map(move |sub| Self::points_of(cx, owner, id, sub))).collect()
|
|
}
|
|
|
|
/// The width point part nearest to `p` within a few pixels: a centre (`None`) or the end of a
|
|
/// left (`Some(true)`) or right (`Some(false)`) handle.
|
|
fn hit(cx: &ToolContext, spot: &Spot, p: Point) -> Option<(WPoint, Option<bool>)> {
|
|
let tol = cx.tol(5.0);
|
|
Self::points_of(cx, spot.owner, spot.id, spot.sub)
|
|
.into_iter()
|
|
.flat_map(|w| [(w, None, w.p), (w, Some(true), w.p + w.n * w.left), (w, Some(false), w.p - w.n * w.right)])
|
|
.map(|(w, part, q)| (q.distance(p), w, part))
|
|
.filter(|h| h.0 <= tol)
|
|
.min_by(|a, b| a.0.total_cmp(&b.0))
|
|
.map(|(_, w, part)| (w, part))
|
|
}
|
|
|
|
fn is_selected(&self, id: NodeId, t: f64) -> bool {
|
|
self.selected.as_ref().is_some_and(|(sid, ts)| *sid == id && ts.iter().any(|s| (s - t).abs() <= SAME_T))
|
|
}
|
|
|
|
/// Shift-click on the width point at `t` of `id`: add it to the selection or take it out.
|
|
/// → whether it is selected now.
|
|
fn toggle(&mut self, id: NodeId, t: f64) -> bool {
|
|
match &mut self.selected {
|
|
Some((sid, ts)) if *sid == id => match ts.iter().position(|s| (s - t).abs() <= SAME_T) {
|
|
Some(i) => {
|
|
ts.remove(i);
|
|
false
|
|
}
|
|
None => {
|
|
ts.push(t);
|
|
true
|
|
}
|
|
},
|
|
_ => {
|
|
self.selected = Some((id, vec![t]));
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The indices of the selected width points of `id` (both sides of a discontinuous point).
|
|
fn selected_indices(&self, cx: &ToolContext, id: NodeId) -> Vec<usize> {
|
|
let Some(pr) = stroke_of(cx.doc, id).and_then(|s| s.profile.as_ref()) else { return vec![] };
|
|
(0..pr.points.len()).filter(|i| self.is_selected(id, pr.points[*i].0)).collect()
|
|
}
|
|
|
|
/// A drag of `id`'s selected points together: a move when more than one profile point is
|
|
/// selected (a discontinuous point moves as one), a width change when several are.
|
|
fn group(&self, cx: &ToolContext, id: NodeId, mode: Mode) -> Option<Group> {
|
|
let st = stroke_of(cx.doc, id)?;
|
|
let moving = self.selected_indices(cx, id);
|
|
let several = match mode {
|
|
Mode::Move => moving.len() > 1,
|
|
Mode::Width(_) => self.selected.as_ref().is_some_and(|(_, ts)| ts.len() > 1),
|
|
Mode::Copy => false,
|
|
};
|
|
several.then(|| Group { points: st.profile.as_ref().map(|p| p.points.clone()).unwrap_or_default(), moving, half: st.width / 2.0 })
|
|
}
|
|
|
|
/// The command previewing drag `d`.
|
|
fn preview(d: &Drag) -> Action {
|
|
let id = d.spot.owner.0;
|
|
if let Some(g) = &d.group {
|
|
let (dt, dl, dr) = (d.t - d.spot.t, (d.left - d.spot.left) / g.half, (d.right - d.spot.right) / g.half);
|
|
let mut pts: Vec<[f64; 3]> = g
|
|
.points
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &(t, l, r))| match (g.moving.contains(&i), d.mode) {
|
|
(true, Mode::Move) => [(t + dt).clamp(0.0, 1.0), l, r],
|
|
(true, _) => [t, (l + dl).max(0.0), (r + dr).max(0.0)],
|
|
(false, _) => [t, l, r],
|
|
})
|
|
.collect();
|
|
pts.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
|
return Action::Preview("stroke.widthProfile.set".into(), json!({"ids": [id], "points": pts}));
|
|
}
|
|
match (d.mode, d.index) {
|
|
(Mode::Copy, Some(i)) => Action::Preview("stroke.widthPoint.copy".into(), json!({"id": id, "index": i, "t": d.t})),
|
|
(_, index) => {
|
|
let mut v = json!({"id": id, "t": d.t, "left": d.left, "right": d.right});
|
|
if let Some(i) = index {
|
|
v["index"] = json!(i);
|
|
}
|
|
Action::Preview("stroke.widthPoint.set".into(), v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Tool for WidthTool {
|
|
fn id(&self) -> &'static str {
|
|
"width"
|
|
}
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
let p = ev.pos;
|
|
match ev.kind {
|
|
PointerKind::Move => {
|
|
self.hover = Self::spot_at(cx, p);
|
|
vec![]
|
|
}
|
|
PointerKind::DoubleClick => {
|
|
let Some(spot) = Self::spot_at(cx, p) else { return vec![] };
|
|
let Some((w, _)) = Self::hit(cx, &spot, p) else { return vec![] };
|
|
self.selected = Some((spot.owner, vec![w.t]));
|
|
vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "index": w.index}))]
|
|
}
|
|
PointerKind::Down => {
|
|
let Some(spot) = Self::spot_at(cx, p) else {
|
|
if !ev.mods.shift {
|
|
self.selected = None;
|
|
}
|
|
return vec![];
|
|
};
|
|
let drag = match Self::hit(cx, &spot, p) {
|
|
Some((w, part)) => {
|
|
let mode = match part {
|
|
None if ev.mods.alt => Mode::Copy,
|
|
None => Mode::Move,
|
|
Some(left) => Mode::Width(ev.mods.alt.then_some(left)),
|
|
};
|
|
if ev.mods.shift {
|
|
if !self.toggle(spot.owner, w.t) {
|
|
return vec![];
|
|
}
|
|
} else if mode == Mode::Copy || !self.is_selected(spot.owner, w.t) {
|
|
self.selected = Some((spot.owner, vec![w.t]));
|
|
}
|
|
let others = Self::points_of(cx, spot.owner, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect();
|
|
let group = self.group(cx, spot.owner, mode);
|
|
let spot = Spot { t: w.t, p: w.p, n: w.n, left: w.left, right: w.right, ..spot };
|
|
Drag { spot, index: Some(w.index), mode, left: w.left, right: w.right, t: w.t, group, others, moved: false }
|
|
}
|
|
None => {
|
|
let side = (p - spot.p).dot(spot.n) >= 0.0;
|
|
self.selected = Some((spot.owner, vec![spot.t]));
|
|
let mode = Mode::Width(ev.mods.alt.then_some(side));
|
|
Drag { spot, index: None, mode, left: spot.left, right: spot.right, t: spot.t, group: None, others: vec![], moved: false }
|
|
}
|
|
};
|
|
let label = if drag.mode == Mode::Copy { "Copy Width Point" } else { "Width Point" };
|
|
self.drag = Some(drag);
|
|
vec![Action::Begin(label.into())]
|
|
}
|
|
PointerKind::Drag => {
|
|
let Some(d) = &mut self.drag else { return vec![] };
|
|
d.moved = true;
|
|
match d.mode {
|
|
Mode::Width(side) => {
|
|
let off = (p - d.spot.p).dot(d.spot.n);
|
|
match side {
|
|
None => {
|
|
d.left = off.abs();
|
|
d.right = off.abs();
|
|
}
|
|
Some(true) => d.left = off.max(0.0),
|
|
Some(false) => d.right = (-off).max(0.0),
|
|
}
|
|
}
|
|
Mode::Move | Mode::Copy => {
|
|
let Some(NodeKind::Path { path, .. }) = cx.doc.node(d.spot.id).map(|n| &n.kind) else { return vec![] };
|
|
let Some(sp) = path.subpaths.get(d.spot.sub) else { return vec![] };
|
|
let Some((_, t, q, tan, _)) = nearest_fraction(&vectorcraft_geom::PathData::single(sp.clone()), p) else { return vec![] };
|
|
(d.t, d.spot.p, d.spot.n) = (t, q, left_normal(tan));
|
|
// Dropped onto another width point (a single point only): it lands exactly
|
|
// there, making a discontinuous point.
|
|
let tol = cx.tol(5.0);
|
|
if d.group.is_none()
|
|
&& let Some(o) = d.others.iter().find(|o| o.p.distance(q) <= tol)
|
|
{
|
|
(d.t, d.spot.p, d.spot.n) = (o.t, o.p, o.n);
|
|
}
|
|
}
|
|
}
|
|
vec![Self::preview(d)]
|
|
}
|
|
PointerKind::Up => match self.drag.take() {
|
|
Some(d) => {
|
|
let dt = d.t - d.spot.t;
|
|
self.selected = match (d.group.is_some(), d.moved, self.selected.take()) {
|
|
// A click on one of several selected points selects just that one.
|
|
(true, false, _) if !ev.mods.shift => Some((d.spot.owner, vec![d.spot.t])),
|
|
// Points moved together stay selected.
|
|
(true, _, Some((id, ts))) if d.mode == Mode::Move => Some((id, ts.into_iter().map(|t| (t + dt).clamp(0.0, 1.0)).collect())),
|
|
(true, _, sel) => sel,
|
|
(false, _, _) => Some((d.spot.owner, vec![d.t])),
|
|
};
|
|
vec![Action::Commit]
|
|
}
|
|
None => vec![],
|
|
},
|
|
}
|
|
}
|
|
/// Delete/Backspace remove the selected width points; with none selected they are the
|
|
/// shortcut's (Clear deletes the selected objects).
|
|
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
|
matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
|
&& self.drag.is_none()
|
|
&& self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty())
|
|
}
|
|
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
|
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
|
|
return vec![];
|
|
}
|
|
let Some(id) = self.selected.as_ref().map(|s| s.0) else { return vec![] };
|
|
let indices = self.selected_indices(cx, id);
|
|
if indices.is_empty() {
|
|
return vec![];
|
|
}
|
|
self.selected = None;
|
|
vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "indices": indices}))]
|
|
}
|
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
|
let mut out = vec![];
|
|
let r = cx.tol(4.0);
|
|
let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover);
|
|
// Every subpath's width points of the stroke under the pointer, and of the selected ones'.
|
|
let show = |owner: NodeId, out: &mut Vec<Overlay>| {
|
|
for w in Self::all_points(cx, owner) {
|
|
let sel = self.is_selected(owner, w.t);
|
|
out.push(Overlay::Line { a: w.p + w.n * w.left, b: w.p - w.n * w.right, color: BLUE, dashed: false });
|
|
out.push(Overlay::Path { path: diamond(w.p, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false });
|
|
out.push(Overlay::Handle { p: w.p + w.n * w.left, color: BLUE });
|
|
out.push(Overlay::Handle { p: w.p - w.n * w.right, color: BLUE });
|
|
}
|
|
};
|
|
if let Some(s) = focus {
|
|
show(s.owner, &mut out);
|
|
}
|
|
if let Some((owner, _)) = &self.selected
|
|
&& focus.is_none_or(|s| s.owner != *owner)
|
|
{
|
|
show(*owner, &mut out);
|
|
}
|
|
if let Some(d) = &self.drag {
|
|
let q = d.spot.p;
|
|
out.push(Overlay::Line { a: q + d.spot.n * d.left, b: q - d.spot.n * d.right, color: BLUE, dashed: false });
|
|
out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: 2.0, dashed: false });
|
|
out.push(Overlay::Measure { p: q + d.spot.n * (d.left + r * 3.0), text: format!("W: {}", cx.stroke_unit.readout(d.left + d.right)) });
|
|
} else if let Some(h) = &self.hover {
|
|
out.push(Overlay::Line { a: h.p + h.n * h.left, b: h.p - h.n * h.right, color: BLUE, dashed: true });
|
|
out.push(Overlay::Path { path: diamond(h.p, r), color: BLUE, width: 1.0, dashed: false });
|
|
}
|
|
out
|
|
}
|
|
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
|
if self.drag.is_some() {
|
|
return Cursor::WidthPoint;
|
|
}
|
|
// The stroke under the pointer, as the last move found it (no search every frame).
|
|
match self.hover {
|
|
Some(spot) if Self::hit(cx, &spot, p).is_some() => Cursor::WidthPoint,
|
|
Some(_) => Cursor::WidthAdd,
|
|
None => Cursor::Width,
|
|
}
|
|
}
|
|
fn busy(&self) -> bool {
|
|
self.drag.is_some()
|
|
}
|
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
|
self.hover = None;
|
|
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testutil::*;
|
|
use vectorcraft_doc::{Appearance, Node, Selection, WidthProfile};
|
|
use vectorcraft_geom::Point;
|
|
use vectorcraft_geom::{PathData, SubPath};
|
|
|
|
fn doc_line() -> (Document, NodeId) {
|
|
let mut d = Document::new(500.0, 500.0);
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
let mut ap = Appearance::default_art();
|
|
ap.stroke_mut().unwrap().width = 10.0;
|
|
let path = PathData::single(SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(300.0, 200.0)], false));
|
|
d.insert(Some(l), 0, Node::path(id, path, ap)).unwrap();
|
|
(d, id)
|
|
}
|
|
|
|
/// The line with width points at t = 0.25, 0.5 and 0.75 (x = 150, 200, 250; 5 pt either side,
|
|
/// the stroke's own width).
|
|
fn doc_three_points() -> (Document, NodeId) {
|
|
let (mut d, id) = doc_line();
|
|
let points = vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.5, 1.0, 1.0), (0.75, 1.0, 1.0), (1.0, 1.0, 1.0)];
|
|
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points });
|
|
(d, id)
|
|
}
|
|
|
|
fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent {
|
|
PointerEvent::new(kind, x, y).with_mods(mods)
|
|
}
|
|
|
|
fn preview(acts: &[Action]) -> (&str, &serde_json::Value) {
|
|
match acts {
|
|
[Action::Preview(cmd, v)] => (cmd.as_str(), v),
|
|
_ => panic!("{acts:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn drag_outward_creates_symmetric_width_point() {
|
|
let (d, id) = doc_line();
|
|
let (s, p) = (Selection::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 201.0)), vec![Action::Begin("Width Point".into())]);
|
|
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0));
|
|
let (cmd, v) = preview(&acts);
|
|
assert_eq!(cmd, "stroke.widthPoint.set");
|
|
assert_eq!(v["id"], json!(id.0));
|
|
assert!((v["t"].as_f64().unwrap() - 0.25).abs() < 1e-3);
|
|
assert!((v["left"].as_f64().unwrap() - 20.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 20.0).abs() < 1e-6);
|
|
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![Action::Commit]);
|
|
}
|
|
|
|
#[test]
|
|
fn alt_drag_changes_one_side_and_move_slides() {
|
|
let (mut d, id) = doc_line();
|
|
let n = d.node_mut(id).unwrap();
|
|
n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] });
|
|
let (s, p) = (Selection::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
// Alt-drag the left handle end (y = 200 - 10) further up.
|
|
let alt = Mods { alt: true, ..Default::default() };
|
|
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 190.0).with_mods(alt));
|
|
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 200.0, 170.0).with_mods(alt));
|
|
let (_, v) = preview(&acts);
|
|
assert_eq!(v["index"], json!(1));
|
|
assert!((v["left"].as_f64().unwrap() - 30.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 10.0).abs() < 1e-6);
|
|
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 170.0));
|
|
let del = t.key(&c, ToolKey::Delete, Mods::default());
|
|
assert_eq!(del, vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "indices": [1]}))]);
|
|
// Drag the centre along the path.
|
|
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0));
|
|
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 205.0));
|
|
let (_, v) = preview(&acts);
|
|
assert!((v["t"].as_f64().unwrap() - 0.75).abs() < 1e-3);
|
|
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, 205.0));
|
|
assert!(t.selected.as_ref().is_some_and(|(_, ts)| (ts[0] - 0.75).abs() < 1e-3), "the moved point stays selected");
|
|
}
|
|
|
|
#[test]
|
|
fn shift_click_selects_several_points_that_drag_together() {
|
|
let (d, id) = doc_three_points();
|
|
let (s, p) = (Selection::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
let (none, shift) = (Mods::default(), Mods { shift: true, ..Default::default() });
|
|
// Click the point at t = 0.25, Shift-click the one at 0.75.
|
|
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none));
|
|
t.pointer(&c, &ev(PointerKind::Up, 150.0, 200.0, none));
|
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, shift)), vec![Action::Begin("Width Point".into())]);
|
|
t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, shift));
|
|
assert_eq!(t.selected_indices(&c, id), vec![1, 3]);
|
|
// Dragging one of them 20 pt along moves both by a tenth of the path, as one command.
|
|
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, none));
|
|
let acts = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 203.0, none));
|
|
let (cmd, v) = preview(&acts);
|
|
assert_eq!((cmd, &v["ids"]), ("stroke.widthProfile.set", &json!([id.0])));
|
|
let ts: Vec<f64> = v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap() * 1000.0).round() / 1000.0).collect();
|
|
assert_eq!(ts, vec![0.0, 0.35, 0.5, 0.85, 1.0]);
|
|
t.pointer(&c, &ev(PointerKind::Up, 170.0, 203.0, none));
|
|
let (_, sel) = t.selected.clone().unwrap();
|
|
assert!((sel[0] - 0.35).abs() < 1e-3 && (sel[1] - 0.85).abs() < 1e-3, "the selection moves with them: {sel:?}");
|
|
// Widening one handle widens every selected point by as much.
|
|
let mut t = WidthTool { selected: Some((id, vec![0.25, 0.75])), ..Default::default() };
|
|
t.pointer(&c, &ev(PointerKind::Down, 250.0, 195.0, none));
|
|
let acts = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 190.0, none));
|
|
let pts = preview(&acts).1["points"].as_array().unwrap().clone();
|
|
let w = |i: usize, side: usize| (pts[i][side].as_f64().unwrap() * 1e6).round() / 1e6;
|
|
assert_eq!((w(1, 1), w(3, 2), w(2, 1)), (2.0, 2.0, 1.0));
|
|
t.pointer(&c, &ev(PointerKind::Up, 250.0, 190.0, none));
|
|
// Shift-clicking a selected point takes it out again (and drags nothing).
|
|
assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, shift)).is_empty());
|
|
assert_eq!(t.selected_indices(&c, id), vec![3]);
|
|
// A plain click on one of several selected points selects just that one.
|
|
let mut t = WidthTool { selected: Some((id, vec![0.25, 0.75])), ..Default::default() };
|
|
t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, none));
|
|
t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, none));
|
|
assert_eq!(t.selected_indices(&c, id), vec![3]);
|
|
}
|
|
|
|
#[test]
|
|
fn alt_drag_copies_and_dropping_onto_a_point_snaps_to_it() {
|
|
let (d, id) = doc_three_points();
|
|
let (s, p) = (Selection::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
let alt = Mods { alt: true, ..Default::default() };
|
|
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, alt)), vec![Action::Begin("Copy Width Point".into())]);
|
|
let acts = t.pointer(&c, &ev(PointerKind::Drag, 180.0, 200.0, alt));
|
|
let (cmd, v) = preview(&acts);
|
|
assert_eq!((cmd, &v["id"], &v["index"]), ("stroke.widthPoint.copy", &json!(id.0), &json!(1)));
|
|
assert!((v["t"].as_f64().unwrap() - 0.4).abs() < 1e-3);
|
|
t.pointer(&c, &ev(PointerKind::Up, 180.0, 200.0, alt));
|
|
// Sliding the point at 0.25 to within a few pixels of the one at 0.5 lands exactly on it.
|
|
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, Mods::default()));
|
|
let acts = t.pointer(&c, &ev(PointerKind::Drag, 197.0, 200.0, Mods::default()));
|
|
let (cmd, v) = preview(&acts);
|
|
assert_eq!((cmd, v["t"].as_f64(), v["index"].as_u64()), ("stroke.widthPoint.set", Some(0.5), Some(1)));
|
|
}
|
|
|
|
/// A compound path of two 200 pt lines (y = 200 and 300) with a 10 pt stroke on the compound
|
|
/// (its members carry none), a width point at t = 0.5 → (doc, compound, second member).
|
|
fn doc_compound() -> (Document, NodeId, NodeId) {
|
|
let mut d = Document::new(500.0, 500.0);
|
|
let l = d.layers[0].id;
|
|
let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
|
let line = |id, y| {
|
|
let path = PathData::single(SubPath::polyline(&[Point::new(100.0, y), Point::new(300.0, y)], false));
|
|
std::sync::Arc::new(Node::path(id, path, Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0)))
|
|
};
|
|
let mut comp = Node::new(c, NodeKind::Compound { children: vec![line(a, 200.0), line(b, 300.0)], rule: Default::default() });
|
|
comp.appearance = Appearance::default_art();
|
|
let st = comp.appearance.stroke_mut().unwrap();
|
|
st.width = 10.0;
|
|
st.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (1.0, 1.0, 1.0)] });
|
|
d.insert(Some(l), 0, comp).unwrap();
|
|
(d, c, b)
|
|
}
|
|
|
|
#[test]
|
|
fn compound_path_strokes_are_edited_through_the_compound() {
|
|
let (d, c, b) = doc_compound();
|
|
assert_eq!(stroke_owner(&d, b), c);
|
|
let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint());
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
// Drag out from the second member: the compound's stroke gets the width point.
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 301.0, Mods::default())), vec![Action::Begin("Width Point".into())]);
|
|
let acts = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 320.0, Mods::default()));
|
|
let (cmd, v) = preview(&acts);
|
|
assert_eq!((cmd, &v["id"]), ("stroke.widthPoint.set", &json!(c.0)));
|
|
t.pointer(&cx, &ev(PointerKind::Up, 150.0, 320.0, Mods::default()));
|
|
// The width point in the middle of the first member selects (and deletes) the compound's.
|
|
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0, Mods::default()));
|
|
t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0, Mods::default()));
|
|
assert!(t.claims_key(&cx, ToolKey::Delete));
|
|
assert_eq!(
|
|
t.key(&cx, ToolKey::Delete, Mods::default()),
|
|
vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": c.0, "indices": [1]}))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn width_points_show_on_every_subpath() {
|
|
let (d, c, _) = doc_compound();
|
|
let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint());
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
let handles = |t: &WidthTool| t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Handle { .. })).count();
|
|
// Hovering one member shows the 3 width points on both (two handle ends each).
|
|
t.pointer(&cx, &ev(PointerKind::Move, 150.0, 302.0, Mods::default()));
|
|
assert_eq!(handles(&t), 3 * 2 * 2);
|
|
// A selected point shows them on every subpath when the pointer is elsewhere.
|
|
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0, Mods::default()));
|
|
t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0, Mods::default()));
|
|
t.pointer(&cx, &ev(PointerKind::Move, 450.0, 450.0, Mods::default()));
|
|
assert_eq!(handles(&t), 3 * 2 * 2);
|
|
// One path of two subpaths: both.
|
|
let (mut d2, id) = doc_line();
|
|
let n = d2.node_mut(id).unwrap();
|
|
if let NodeKind::Path { path, .. } = &mut n.kind {
|
|
path.subpaths.push(SubPath::polyline(&[Point::new(100.0, 260.0), Point::new(300.0, 260.0)], false));
|
|
}
|
|
n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] });
|
|
let s2 = Selection { objects: vec![id], ..Default::default() };
|
|
let cx2 = crate::testutil::cx(&d2, &s2, &p);
|
|
let mut t = WidthTool::default();
|
|
t.pointer(&cx2, &ev(PointerKind::Down, 200.0, 260.0, Mods::default()));
|
|
t.pointer(&cx2, &ev(PointerKind::Up, 200.0, 260.0, Mods::default()));
|
|
t.pointer(&cx2, &ev(PointerKind::Move, 450.0, 450.0, Mods::default()));
|
|
let ends: Vec<Point> = t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Handle { p, .. } = o { Some(*p) } else { None }).collect();
|
|
assert!(ends.iter().any(|q| q.y < 230.0) && ends.iter().any(|q| q.y > 230.0), "both subpaths: {ends:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_cursor_says_what_a_drag_does() {
|
|
let (d, _) = doc_three_points();
|
|
let (s, p) = (Selection::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
assert_eq!(t.cursor(&c, Point::new(400.0, 400.0), Mods::default()), Cursor::Width);
|
|
t.pointer(&c, &ev(PointerKind::Move, 170.0, 201.0, Mods::default()));
|
|
assert_eq!(t.cursor(&c, Point::new(170.0, 201.0), Mods::default()), Cursor::WidthAdd, "on the stroke: adds a point");
|
|
t.pointer(&c, &ev(PointerKind::Move, 150.0, 200.0, Mods::default()));
|
|
assert_eq!(t.cursor(&c, Point::new(150.0, 200.0), Mods::default()), Cursor::WidthPoint, "on a width point");
|
|
assert_eq!(t.cursor(&c, Point::new(150.0, 195.0), Mods::default()), Cursor::WidthPoint, "on a handle end");
|
|
}
|
|
|
|
#[test]
|
|
fn double_click_on_a_width_point_opens_width_point_edit() {
|
|
let (d, id) = doc_three_points();
|
|
let (s, p) = (Selection::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = WidthTool::default();
|
|
let acts = t.pointer(&c, &ev(PointerKind::DoubleClick, 200.0, 195.0, Mods::default()));
|
|
assert_eq!(acts, vec![Action::Dialog("widthPoint".into(), json!({"id": id.0, "index": 2}))]);
|
|
assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, 120.0, 200.0, Mods::default())).is_empty(), "not on a width point");
|
|
}
|
|
}
|