Merge pull request #197 from storytold/m8-width-tool
M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors
This commit is contained in:
commit
55d7df36e4
|
|
@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
|||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes) | 8–12 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors) | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue