mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors
- A compound path's stroke lives on the compound: the tool edits it through any member, and stroke.widthPoint.* / stroke.widthProfile.set given a member edit the compound's profile. - Width points show on every subpath the stroke runs along (not just the first) for the hovered and the selected stroke. - Cursors: the Width tool's own glyphs off a stroke, over a stroke (a drag adds a point) and over a width point or handle (a drag moves or widens it). - Hint-bar text for the tool. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
bd91cf9123
commit
13db157d36
12 files changed
+318
-38
No files matched your search
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile}
|
||||
use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect};
|
||||
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds};
|
||||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, warp_from_rest, warp_node_with};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, stroke_owner, warp_from_rest, warp_node_with};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::*;
|
||||
@@ -194,10 +194,16 @@ fn uniform() -> WidthProfile {
|
||||
/// Width points closer than this (in t) are at the same place: a discontinuous point.
|
||||
const SAME_T: f64 = 1e-6;
|
||||
|
||||
/// The weighted stroke of `id` that width point commands edit.
|
||||
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
|
||||
/// The stroke of `id` that width point commands edit (a compound path's for its members).
|
||||
fn stroke_mut(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
|
||||
let id = stroke_owner(d, id);
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
|
||||
n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))
|
||||
}
|
||||
|
||||
/// [`stroke_mut`], which must have a weight.
|
||||
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
|
||||
let st = stroke_mut(d, id)?;
|
||||
if st.width <= 0.0 {
|
||||
return Err(EngineError::Other("the stroke has no weight".into()));
|
||||
}
|
||||
@@ -316,8 +322,7 @@ fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
s.edit("Delete Width Point", |d, _| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
|
||||
let st = stroke_mut(d, id)?;
|
||||
let fits = |pr: &&mut WidthProfile| indices.last().is_some_and(|i| *i < pr.points.len());
|
||||
let prof = st.profile.as_mut().filter(fits).ok_or_else(|| bad(C, "no such width point"))?;
|
||||
for i in indices.iter().rev() {
|
||||
@@ -351,7 +356,10 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(C, "points must be an array or null")),
|
||||
};
|
||||
let ids = targets(s, p)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
s.edit("Width Profile", |d, _| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
|
||||
@@ -1376,4 +1376,6 @@ mod tests_widthpoints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The Width tool on compound paths: the stroke is the compound's, so its members are edited
|
||||
//! through it, by the tool and by the width point commands given a member.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Two 200 pt lines made into a compound path with a 10 pt stroke → (compound, members).
|
||||
fn compound() -> (Session, NodeId, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
|
||||
let members: Vec<NodeId> = [200, 300]
|
||||
.iter()
|
||||
.map(|y| NodeId(s.execute("shape.line", &json!({"x1": 100, "y1": y, "x2": 300, "y2": y})).unwrap()["id"].as_u64().unwrap()))
|
||||
.collect();
|
||||
s.execute("select.set", &json!({"ids": [members[0].0, members[1].0]})).unwrap();
|
||||
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
|
||||
(s, c, members)
|
||||
}
|
||||
|
||||
fn profile(s: &Session, id: NodeId) -> Option<Vec<(f64, f64, f64)>> {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|st| st.profile.as_ref().map(|p| p.points.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_width_tool_edits_a_compound_paths_stroke() {
|
||||
let (mut s, c, members) = compound();
|
||||
assert!(s.doc().unwrap().doc.node(members[1]).is_some(), "members keep their ids");
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("width", v).unwrap();
|
||||
// Drag out from the second member's middle: 20 pt either side.
|
||||
for (k, y) in [(PointerKind::Down, 301.0), (PointerKind::Drag, 310.0), (PointerKind::Drag, 320.0), (PointerKind::Up, 320.0)] {
|
||||
s.pointer(&PointerEvent::new(k, 200.0, y), v).unwrap();
|
||||
}
|
||||
let p = profile(&s, c).expect("the compound's stroke has the width point");
|
||||
assert!(p.iter().any(|q| (q.0 - 0.5).abs() < 1e-3 && (q.1 - 4.0).abs() < 1e-6), "{p:?}");
|
||||
assert_eq!(s.journal.last().unwrap().1["id"], json!(c.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_point_commands_given_a_member_edit_the_compound() {
|
||||
let (mut s, c, members) = compound();
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": members[0].0, "t": 0.25, "left": 10, "right": 10})).unwrap();
|
||||
assert_eq!(profile(&s, c).unwrap().len(), 3);
|
||||
s.execute("stroke.widthPoint.remove", &json!({"id": members[1].0, "index": 1})).unwrap();
|
||||
assert_eq!(profile(&s, c).unwrap().len(), 2);
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [members[0].0, members[1].0], "points": [[0, 0, 0], [1, 2, 2]]})).unwrap();
|
||||
assert_eq!(profile(&s, c).unwrap(), vec![(0.0, 0.0, 0.0), (1.0, 2.0, 2.0)]);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use crate::Tool;
|
||||
pub use liquify::LiquifyTool;
|
||||
pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool};
|
||||
pub use puppet::{PinSet, PuppetWarpTool, mesh_for, mesh_input, rest_and_pins, warp_from_rest};
|
||||
pub use width::WidthTool;
|
||||
pub use width::{WidthTool, stroke_owner};
|
||||
|
||||
/// Create a distortion tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
//! 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};
|
||||
@@ -28,6 +32,9 @@ 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.
|
||||
@@ -93,12 +100,31 @@ struct Drag {
|
||||
pub struct WidthTool {
|
||||
hover: Option<Spot>,
|
||||
drag: Option<Drag>,
|
||||
/// Selected width points: the path and their positions t.
|
||||
/// Selected width points: the stroke's owner and their positions t.
|
||||
selected: Option<(NodeId, Vec<f64>)>,
|
||||
}
|
||||
|
||||
fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> {
|
||||
doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
|
||||
/// 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`.
|
||||
@@ -116,7 +142,8 @@ impl WidthTool {
|
||||
}
|
||||
let n = cx.doc.node(id)?;
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
|
||||
let st = stroke_of(cx.doc, id)?;
|
||||
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) {
|
||||
@@ -127,10 +154,11 @@ impl WidthTool {
|
||||
return None;
|
||||
}
|
||||
let (left, right) = sides_at(st, t);
|
||||
Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right }))
|
||||
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));
|
||||
if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) {
|
||||
// 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![];
|
||||
@@ -142,9 +170,9 @@ impl WidthTool {
|
||||
best_of(&mut all.into_iter().rev()).map(|b| b.1)
|
||||
}
|
||||
|
||||
/// Width points of `id` on subpath `sub`.
|
||||
fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec<WPoint> {
|
||||
let Some(st) = stroke_of(cx.doc, id) else { return vec![] };
|
||||
/// 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![] };
|
||||
@@ -158,11 +186,20 @@ impl WidthTool {
|
||||
.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.id, spot.sub)
|
||||
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))
|
||||
@@ -217,7 +254,7 @@ impl WidthTool {
|
||||
|
||||
/// The command previewing drag `d`.
|
||||
fn preview(d: &Drag) -> Action {
|
||||
let id = d.spot.id.0;
|
||||
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
|
||||
@@ -260,8 +297,8 @@ impl Tool for WidthTool {
|
||||
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.id, vec![w.t]));
|
||||
vec![Action::Dialog("widthPoint".into(), json!({"id": spot.id.0, "index": w.index}))]
|
||||
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 {
|
||||
@@ -278,20 +315,20 @@ impl Tool for WidthTool {
|
||||
Some(left) => Mode::Width(ev.mods.alt.then_some(left)),
|
||||
};
|
||||
if ev.mods.shift {
|
||||
if !self.toggle(spot.id, w.t) {
|
||||
if !self.toggle(spot.owner, w.t) {
|
||||
return vec![];
|
||||
}
|
||||
} else if mode == Mode::Copy || !self.is_selected(spot.id, w.t) {
|
||||
self.selected = Some((spot.id, vec![w.t]));
|
||||
} 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.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect();
|
||||
let group = self.group(cx, spot.id, mode);
|
||||
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.id, vec![spot.t]));
|
||||
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 }
|
||||
}
|
||||
@@ -337,11 +374,11 @@ impl Tool for WidthTool {
|
||||
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.id, vec![d.spot.t])),
|
||||
(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.id, vec![d.t])),
|
||||
(false, _, _) => Some((d.spot.owner, vec![d.t])),
|
||||
};
|
||||
vec![Action::Commit]
|
||||
}
|
||||
@@ -372,9 +409,10 @@ impl Tool for WidthTool {
|
||||
let mut out = vec![];
|
||||
let r = cx.tol(4.0);
|
||||
let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover);
|
||||
let show = |id: NodeId, sub: usize, out: &mut Vec<Overlay>| {
|
||||
for w in Self::points_of(cx, id, sub) {
|
||||
let sel = self.is_selected(id, w.t);
|
||||
// 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 });
|
||||
@@ -382,12 +420,12 @@ impl Tool for WidthTool {
|
||||
}
|
||||
};
|
||||
if let Some(s) = focus {
|
||||
show(s.id, s.sub, &mut out);
|
||||
show(s.owner, &mut out);
|
||||
}
|
||||
if let Some((id, _)) = &self.selected
|
||||
&& focus.is_none_or(|s| s.id != *id)
|
||||
if let Some((owner, _)) = &self.selected
|
||||
&& focus.is_none_or(|s| s.owner != *owner)
|
||||
{
|
||||
show(*id, 0, &mut out);
|
||||
show(*owner, &mut out);
|
||||
}
|
||||
if let Some(d) = &self.drag {
|
||||
let q = d.spot.p;
|
||||
@@ -400,8 +438,16 @@ impl Tool for WidthTool {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
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()
|
||||
@@ -417,6 +463,7 @@ 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) {
|
||||
@@ -554,6 +601,94 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -312,6 +312,12 @@ pub enum Cursor {
|
||||
Slice,
|
||||
/// The Slice Selection tool: the arrow with a slice badge.
|
||||
SliceSelect,
|
||||
/// The Width tool away from strokes.
|
||||
Width,
|
||||
/// The Width tool over a stroke: a drag adds a width point.
|
||||
WidthAdd,
|
||||
/// The Width tool over a width point or a handle end: a drag moves or widens it.
|
||||
WidthPoint,
|
||||
/// The Blend tool away from art: a crosshair with a hollow square.
|
||||
Blend,
|
||||
/// The Blend tool over an object it can blend: a crosshair with a filled square.
|
||||
|
||||
@@ -394,6 +394,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::RemoveStop => C::NotAllowed,
|
||||
Cursor::Slice => C::Crosshair,
|
||||
Cursor::SliceSelect => C::Default,
|
||||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||||
Cursor::WidthPoint => C::Move,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +490,18 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
|
||||
],
|
||||
"gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)],
|
||||
"artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)],
|
||||
"width" => &[
|
||||
("Drag", true),
|
||||
(" a stroke to add a width point | ", false),
|
||||
("Alt+Drag", true),
|
||||
(" to change one side | ", false),
|
||||
("Shift+Click", true),
|
||||
(" to select more points | ", false),
|
||||
("Double-click", true),
|
||||
(" a point to edit it | ", false),
|
||||
("Delete", true),
|
||||
(" to remove points", false),
|
||||
],
|
||||
"puppetWarp" => &[
|
||||
("Click", true),
|
||||
(" the art to add a pin | ", false),
|
||||
|
||||
@@ -133,6 +133,33 @@ fn slice(p: &Painter, o: Pos2) {
|
||||
line(p, b + vec2(8.0, 6.0), b + vec2(12.0, 12.0));
|
||||
}
|
||||
|
||||
/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point),
|
||||
/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point
|
||||
/// (a drag moves or widens it).
|
||||
fn width(p: &Painter, o: Pos2, badge: &str) {
|
||||
arrow(p, o, true);
|
||||
let b = o + vec2(11.0, 18.0);
|
||||
let top: Vec<Pos2> = (0..=8)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 8.0;
|
||||
b + vec2(12.0 * t, -3.5 * (std::f32::consts::PI * t).sin())
|
||||
})
|
||||
.collect();
|
||||
let mut lens = top.clone();
|
||||
lens.extend(top.iter().rev().map(|q| pos2(q.x, 2.0 * b.y - q.y)));
|
||||
p.add(Shape::closed_line(lens.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::closed_line(lens, Stroke::new(1.2, INK)));
|
||||
match badge {
|
||||
"+" => {
|
||||
let c = b + vec2(16.0, -6.0);
|
||||
line(p, c - vec2(3.0, 0.0), c + vec2(3.0, 0.0));
|
||||
line(p, c - vec2(0.0, 3.0), c + vec2(0.0, 3.0));
|
||||
}
|
||||
"point" => line(p, b + vec2(6.0, -6.0), b + vec2(6.0, 6.0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn ibeam(p: &Painter, o: Pos2) {
|
||||
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
||||
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
||||
@@ -185,6 +212,9 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
||||
arrow(painter, p, false);
|
||||
slice_badge(painter, p + vec2(11.0, 14.0));
|
||||
}
|
||||
Cursor::Width => width(painter, p, ""),
|
||||
Cursor::WidthAdd => width(painter, p, "+"),
|
||||
Cursor::WidthPoint => width(painter, p, "point"),
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ mod tests_sysclip;
|
||||
mod tests_sysclip_emf;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! The Width tool in the UI: its cursors and hint.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_tools::Cursor;
|
||||
|
||||
use crate::{VectorcraftApp, chrome, tests_labels::painted_text};
|
||||
|
||||
#[test]
|
||||
fn the_width_tool_has_its_own_cursors() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let painter = ui.painter().clone();
|
||||
for c in [Cursor::Width, Cursor::WidthAdd, Cursor::WidthPoint] {
|
||||
assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0)), "{c:?} is drawn");
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(out.shapes.len() > 3, "{} shapes", out.shapes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hint_bar_explains_the_width_tool() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "width"})).unwrap();
|
||||
let text = painted_text(&mut app, chrome::hint_bar).replace('\n', "");
|
||||
assert!(text.contains("add a width point") && text.contains("one side") && text.contains("remove points"), "{text}");
|
||||
}
|
||||
@@ -873,6 +873,8 @@ points}` replaces them all, as dragging several Shift-selected points with the W
|
||||
With the Width tool, `press_key` Delete or Backspace removes the selected width points (`handledBy: "tool"`); with none
|
||||
selected the key clears the selected objects as usual (`edit.clear`). The Puppet Warp tool's selected pins go the
|
||||
same way.
|
||||
A compound path's stroke is the compound's: width point commands given one of its members edit the compound's
|
||||
profile, which runs along every subpath (the Width tool shows each subpath's points).
|
||||
|
||||
`object.puppetWarp {ids?, pins, moved, angles?, expand?}` warps art as rigidly as possible so that each pin (a point
|
||||
on the art) lands on its `moved` point. `angles` (degrees, or null for a free pin; as long as `pins`) holds the turn
|
||||
|
||||
Reference in new issue
Block a user