From b79262d4f4c32a6f0214a62ca48789681dd72241 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:44:33 +0700 Subject: [PATCH] M3.64: Width tool depth: Width Point Edit, multi-select, Alt-copy and discontinuous points Double-clicking a width point opens Width Point Edit (Side 1, Side 2, Total Width, a link keeping the sides equal, Adjust Adjoining Width Points, Delete; dialog `widthPoint`, also `ui.widthPointEdit`). Shift-click selects several width points, which drag and widen together as one undo step; Alt-dragging a point's centre copies it (`stroke.widthPoint.copy`); dropping a point onto another makes a discontinuous point (two points at the same t), and the stroke's width steps there. `stroke.widthPoint.set` takes `adjustAdjoining`, keeps a discontinuous point when only its widths change, and `stroke.widthPoint.remove` takes `indices`. Flipping a profile along the path swaps the sides of its steps; `document.inspect` reports `widthPoints`. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 17 +- crates/effects/src/stroke/width.rs | 41 ++- crates/engine/src/cmd/distortcmds.rs | 146 +++++++-- crates/engine/src/cmd/stroke.rs | 5 +- crates/engine/src/inspect.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_widthpoints.rs | 132 ++++++++ crates/tools/src/distort/width.rs | 372 ++++++++++++++++++---- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/tools.rs | 7 + crates/ui-egui/src/dialogs/width_point.rs | 187 +++++++++++ crates/ui-egui/src/menus.rs | 7 + docs/control-protocol.md | 5 + docs/mcp.md | 16 + 14 files changed, 842 insertions(+), 101 deletions(-) create mode 100644 crates/engine/src/tests_widthpoints.rs create mode 100644 crates/ui-egui/src/dialogs/width_point.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index ed2e68b7..386f5604 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -203,7 +203,9 @@ pub enum ArrowAlign { Tip, } -/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor). +/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor), +/// in order along the path. Two points at the same position make a discontinuous point: the +/// first is the width just before it, the second the width just after it (a step). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct WidthProfile { pub points: Vec<(f64, f64, f64)>, @@ -277,6 +279,19 @@ impl WidthProfile { pub fn taper_start() -> Self { Self::preset("taperStart").expect("built-in") } + /// The (left, right) factors just before and just after `t`: they differ only at a + /// discontinuous point (two points at `t`, within `1e-7`). + pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { + let p = &self.points; + if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { + let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; + if j > i { + return ((p[i].1, p[i].2), (p[j].1, p[j].2)); + } + } + let v = self.at(t); + (v, v) + } } /// A width profile saved to the Profile list under a name (Add to Profiles), kept with the diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 41d1481d..e2eff27b 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -85,7 +85,8 @@ fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) } /// Insert samples where the profile has width points (so the piecewise-linear profile is exact -/// along straight runs). `span` maps the subpath onto the profile (see [`outline_spans`]). +/// along straight runs; the two points of a discontinuous point share one sample). `span` maps the +/// subpath onto the profile (see [`outline_spans`]). fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec) { let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; @@ -105,6 +106,7 @@ fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, out.1.push(corner[i]); let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); inner.sort_by(f64::total_cmp); + inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { out.0.push(a.lerp(b, (d - acc) / len)); out.1.push(false); @@ -153,26 +155,34 @@ impl Ctx<'_> { if total <= 1e-12 { return; } - // Left and right offsets at `d` along this piece. + // Left and right offsets just before and just after `d` along this piece (they differ + // at a discontinuous point). let width_at = |d: f64| { let t = span.0 + (span.1 - span.0) * d / total; - let (l, r) = self.profile.at(if t > 1.0 { t - 1.0 } else { t }); - (self.half * l.max(0.0), self.half * r.max(0.0)) + let (b, a) = self.profile.around(if t > 1.0 { t - 1.0 } else { t }); + let side = |(l, r): (f64, f64)| (self.half * l.max(0.0), self.half * r.max(0.0)); + (side(b), side(a)) }; let mut lpts = Vec::with_capacity(n); let mut rpts = Vec::with_capacity(n); for (i, &p) in pts.iter().enumerate() { - let (wl, wr) = width_at(cum[i]); + let (before, after) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { let nm = left(seg_dir[i.min(seg_count - 1)]); + let (wl, wr) = if i == 0 { after } else { before }; lpts.push(p + nm * wl); rpts.push(p - nm * wr); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; - self.join(&mut lpts, &v, wl, 1.0); - self.join(&mut rpts, &v, wr, -1.0); + // A step: the side goes straight out (or in) from the width before to the one after. + for (k, (wl, wr)) in [before, after].into_iter().enumerate() { + if k == 0 || (wl, wr) != before { + self.join(&mut lpts, &v, wl, 1.0); + self.join(&mut rpts, &v, wr, -1.0); + } + } } if closed { polygon(out, lpts.into_iter()); @@ -180,7 +190,7 @@ impl Ctx<'_> { return; } let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); - let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0), width_at(total)); + let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap); @@ -304,6 +314,21 @@ mod tests { assert!(bb.y0 < -4.99 && bb.y1 <= 1e-9, "{bb:?}"); } + #[test] + fn a_discontinuous_point_steps_the_width() { + // 5 pt either side for the first half, 10 pt for the second: a step at the middle. + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)] }; + let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); + assert!((area(&o) - 1500.0).abs() < 1e-6, "{}", area(&o)); + // No ramp: just before the middle the outline is still 5 pt from the line. + assert!(!o.contains(kurbo::Point::new(49.0, -6.0)) && o.contains(kurbo::Point::new(51.0, -6.0))); + // A step at a corner of a polyline renders too. + let mut l = line(); + l.line_to((100.0, 100.0)); + let a = area(&width_outline(&l, 10.0, &p, &capped(LineCap::Butt), 0.01)); + assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); + } + #[test] fn closed_paths_make_a_ring() { let mut sq = BezPath::new(); diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 976b7a82..270cada2 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -24,11 +24,28 @@ pub fn specs() -> Vec { "Width Point", [], None, - "{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to move/replace} → {index}. Creates a uniform profile first if needed", + "{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (with index: the nearest points either side change their widths in proportion)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed", has_doc, width_point_set ), - cmd!("stroke.widthPoint.remove", "Delete Width Point", [], None, "{id, index}", has_doc, width_point_remove), + cmd!( + "stroke.widthPoint.remove", + "Delete Width Point", + [], + None, + "{id, index | indices: [..]} remove width points (removing the last one leaves a uniform stroke)", + has_doc, + width_point_remove + ), + cmd!( + "stroke.widthPoint.copy", + "Copy Width Point", + [], + None, + "{id, index: the width point to copy, t: 0..1 where the copy goes} → {index} (Alt-drag with the Width tool). Landing on another point makes a discontinuous point, as a stroke.widthPoint.set move does", + has_doc, + width_point_copy + ), cmd!( "stroke.widthProfile.set", "Width Profile", @@ -129,6 +146,64 @@ fn uniform() -> WidthProfile { WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] } } +/// 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> { + 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()))?; + if st.width <= 0.0 { + return Err(EngineError::Other("the stroke has no weight".into())); + } + Ok(st) +} + +/// The `index` param, checked against the points of `prof`. +fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result> { + match p.get("index").and_then(Value::as_u64).map(|v| v as usize) { + Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")), + i => Ok(i), + } +} + +/// Insert width point `pt` in order. Landing on another point's position makes a discontinuous +/// point: `pt` snaps to it and takes the side it came from (`from_below`: before it), replacing +/// that side of a discontinuous point already there. → where it landed. +fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below: bool) -> usize { + let at: Vec = (0..points.len()).filter(|i| (points[*i].0 - pt.0).abs() <= SAME_T).collect(); + let (Some(&first), Some(&last)) = (at.first(), at.last()) else { + let pos = points.iter().position(|q| q.0 > pt.0).unwrap_or(points.len()); + points.insert(pos, pt); + return pos; + }; + pt.0 = points[first].0; + let pair = at.len() > 1; + let pos = match (from_below, pair) { + (true, _) => first, + (false, true) => last, + (false, false) => last + 1, + }; + if pair { + points.remove(pos); + } + points.insert(pos, pt); + pos +} + +/// Adjust Adjoining Width Points: point `i` changes from `old` to `new` widths, and the nearest +/// points at other positions either side change in proportion (by the same amount from zero). +fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) { + let t = points[i].0; + let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0); + let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T); + let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T); + for j in before.into_iter().chain(after) { + let q = &mut points[j]; + (q.1, q.2) = (scale(q.1, old.1, new.1), scale(q.2, old.2, new.2)); + } +} + fn width_point_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.set"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; @@ -137,24 +212,48 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result { if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { return Err(bad(C, "left/right must be non-negative widths")); } - let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize); + let adjust = bool_or(p, "adjustAdjoining", false); let pos = s.edit("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()))?; - if st.width <= 0.0 { - return Err(EngineError::Other("the stroke has no weight".into())); - } + let st = weighted_stroke(d, id)?; let half = st.width / 2.0; let mut prof = st.profile.take().unwrap_or_else(uniform); - if let Some(i) = index { - if i >= prof.points.len() { - return Err(bad(C, "no such width point")); + let pt = (t, left / half, right / half); + let pos = match point_index(p, &prof, C)? { + Some(i) => { + let old = prof.points[i]; + if adjust { + adjust_adjoining(&mut prof.points, i, old, pt); + } + if (old.0 - t).abs() <= SAME_T { + // Widths only: it keeps its place (and its side of a discontinuous point). + prof.points[i] = (old.0, pt.1, pt.2); + i + } else { + prof.points.remove(i); + place(&mut prof.points, pt, old.0 < t) + } } - prof.points.remove(i); - } - prof.points.retain(|q| (q.0 - t).abs() > 1e-6); - let pos = prof.points.iter().position(|q| q.0 > t).unwrap_or(prof.points.len()); - prof.points.insert(pos, (t, left / half, right / half)); + None => { + prof.points.retain(|q| (q.0 - t).abs() > SAME_T); + place(&mut prof.points, pt, true) + } + }; + st.profile = Some(prof); + Ok(pos) + })?; + Ok(json!({ "index": pos })) +} + +fn width_point_copy(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthPoint.copy"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); + let pos = s.edit("Copy Width Point", |d, _| { + let st = weighted_stroke(d, id)?; + let mut prof = st.profile.take().unwrap_or_else(uniform); + let i = point_index(p, &prof, C)?.ok_or_else(|| bad(C, "missing index"))?; + let (from, l, r) = prof.points[i]; + let pos = place(&mut prof.points, (t, l, r), from < t); st.profile = Some(prof); Ok(pos) })?; @@ -164,12 +263,21 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result { fn width_point_remove(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.remove"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize; + let mut indices: Vec = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) { + (Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(), + (None, Some(i)) => vec![i as usize], + (None, None) => return Err(bad(C, "missing index")), + }; + 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 prof = st.profile.as_mut().filter(|pr| index < pr.points.len()).ok_or_else(|| bad(C, "no such width point"))?; - prof.points.remove(index); + 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() { + prof.points.remove(*i); + } if prof.points.is_empty() { st.profile = None; } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 03e805da..782c17ff 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -294,10 +294,11 @@ fn encloses(n: &Node) -> bool { } } -/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). +/// Mirror a width profile along the path (t → 1 − t; the two sides of a discontinuous point swap +/// too) or across it (swap left/right widths). pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile { let mut pts: Vec<(f64, f64, f64)> = - if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() }; + if along { p.points.iter().rev().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() }; pts.sort_by(|a, b| a.0.total_cmp(&b.0)); WidthProfile { points: pts } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index f1fe238a..f7aec419 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -95,7 +95,8 @@ pub fn node_stroke(n: &Node) -> Option { } /// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when -/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile. +/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile, +/// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform). pub fn stroke_options(st: &StrokeLayer) -> Value { json!({ "cap": name_of(&CAPS, st.cap), @@ -109,6 +110,7 @@ pub fn stroke_options(st: &StrokeLayer) -> Value { "endArrow": st.end_arrow, "arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" }, "profile": WidthProfile::id_of(st.profile.as_ref()), + "widthPoints": st.profile.as_ref().map(|p| &p.points), }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 54a4e8d2..8552a66e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -944,6 +944,8 @@ mod tests_tints; #[cfg(test)] mod tests_transparencygrid; #[cfg(test)] +mod tests_widthpoints; +#[cfg(test)] mod tests_widthprofiles; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_widthpoints.rs b/crates/engine/src/tests_widthpoints.rs new file mode 100644 index 00000000..de287868 --- /dev/null +++ b/crates/engine/src/tests_widthpoints.rs @@ -0,0 +1,132 @@ +//! Width tool depth (M3.64): discontinuous width points, copies, multi-point drags, Adjust +//! Adjoining Width Points and the render of a step. + +use serde_json::json; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +/// A horizontal line from (20, 100) to (180, 100), 10 pt black stroke. +fn line_session() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("stroke.set", &json!({"ids": [id.0], "weight": 10})).unwrap(); + (s, id) +} + +fn points(s: &Session, id: NodeId) -> Vec<(f64, f64, f64)> { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.as_ref().map(|p| p.points.clone()).unwrap_or_default() +} + +fn set(s: &mut Session, p: serde_json::Value) -> usize { + s.execute("stroke.widthPoint.set", &p).unwrap()["index"].as_u64().unwrap() as usize +} + +fn dark(s: &Session, x: u32, y: u32) -> bool { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128 +} + +#[test] +fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() { + let (mut s, id) = line_session(); + set(&mut s, json!({"id": id.0, "t": 0.25, "left": 5, "right": 5})); + set(&mut s, json!({"id": id.0, "t": 0.75, "left": 15, "right": 15})); + // Move the thin point (index 1) onto the wide one: it joins it from below, as its first side. + let i = set(&mut s, json!({"id": id.0, "index": 1, "t": 0.75, "left": 5, "right": 5})); + assert_eq!(i, 1); + assert_eq!(points(&s, id), vec![(0.0, 1.0, 1.0), (0.75, 1.0, 1.0), (0.75, 3.0, 3.0), (1.0, 1.0, 1.0)]); + // Editing one side's widths keeps the pair. + set(&mut s, json!({"id": id.0, "index": 1, "t": 0.75, "left": 2, "right": 2})); + assert_eq!(points(&s, id)[1..3], [(0.75, 0.4, 0.4), (0.75, 3.0, 3.0)]); + let doc = s.execute("document.inspect", &json!({})).unwrap(); + let want = json!([[0.0, 1.0, 1.0], [0.75, 0.4, 0.4], [0.75, 3.0, 3.0], [1.0, 1.0, 1.0]]); + assert_eq!(doc["layers"][0]["children"][0]["strokeOptions"]["widthPoints"], want, "agents read the points"); + // The step renders: just before x = 140 (t = 0.75) the stroke is 2 pt thick, just after 15. + assert!(!dark(&s, 137, 92) && dark(&s, 143, 92), "a step, not a ramp"); + // A new point at that place replaces both; a pair round-trips through the native format. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let n = back.node(id).unwrap(); + assert_eq!(n.appearance.stroke().unwrap().profile.as_ref().unwrap().points, points(&s, id)); + set(&mut s, json!({"id": id.0, "t": 0.75, "left": 5, "right": 5})); + assert_eq!(points(&s, id).len(), 3); +} + +#[test] +fn copy_adds_one_point_and_the_tool_alt_drag_copies() { + let (mut s, id) = line_session(); + set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})); + let r = s.execute("stroke.widthPoint.copy", &json!({"id": id.0, "index": 1, "t": 0.25})).unwrap(); + assert_eq!(r["index"], json!(1)); + assert_eq!(points(&s, id), vec![(0.0, 1.0, 1.0), (0.25, 2.0, 2.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)]); + assert!(s.execute("stroke.widthPoint.copy", &json!({"id": id.0, "t": 0.25})).is_err(), "needs index"); + // Alt-drag the centre of the point at 0.5 (x = 100) to x = 140: one more point, one undo step. + let undo = s.doc().unwrap().history.undo.len(); + let v = ViewInfo::default(); + s.select_tool("width", v).unwrap(); + let alt = Mods { alt: true, ..Default::default() }; + for (kind, x) in [(PointerKind::Down, 100.0), (PointerKind::Drag, 120.0), (PointerKind::Drag, 140.0), (PointerKind::Up, 140.0)] { + s.pointer(&PointerEvent::new(kind, x, 100.0).with_mods(alt), v).unwrap(); + } + let pts = points(&s, id); + assert_eq!(pts.len(), 5); + assert!((pts[3].0 - 0.75).abs() < 1e-3 && pts[3].1 == 2.0 && pts[2] == (0.5, 2.0, 2.0), "{pts:?}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Copy Width Point"); +} + +#[test] +fn shift_selected_points_drag_together_in_one_step() { + let (mut s, id) = line_session(); + set(&mut s, json!({"id": id.0, "t": 0.25, "left": 5, "right": 5})); + set(&mut s, json!({"id": id.0, "t": 0.5, "left": 5, "right": 5})); + let v = ViewInfo::default(); + s.select_tool("width", v).unwrap(); + let (none, shift) = (Mods::default(), Mods { shift: true, ..Default::default() }); + let undo = s.doc().unwrap().history.undo.len(); + // Click the point at x = 60, Shift-click the one at x = 100, then drag the first 16 pt along. + for (kind, x, m) in [ + (PointerKind::Down, 60.0, none), + (PointerKind::Up, 60.0, none), + (PointerKind::Down, 100.0, shift), + (PointerKind::Up, 100.0, shift), + (PointerKind::Down, 60.0, none), + (PointerKind::Drag, 70.0, none), + (PointerKind::Drag, 76.0, none), + (PointerKind::Up, 76.0, none), + ] { + s.pointer(&PointerEvent::new(kind, x, 100.0).with_mods(m), v).unwrap(); + } + let ts: Vec = points(&s, id).iter().map(|p| (p.0 * 100.0).round() / 100.0).collect(); + assert_eq!(ts, vec![0.0, 0.35, 0.6, 1.0]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step for the drag (clicks change nothing)"); + // Delete removes both selected points. + s.tool_key(vectorcraft_tools::ToolKey::Delete, none, v).unwrap(); + assert_eq!(points(&s, id).len(), 2); +} + +#[test] +fn adjust_adjoining_scales_the_neighbours() { + let (mut s, id) = line_session(); + set(&mut s, json!({"id": id.0, "t": 0.5, "left": 5, "right": 5})); + // Doubling the middle point with Adjust Adjoining doubles the end points too. + set(&mut s, json!({"id": id.0, "index": 1, "t": 0.5, "left": 10, "right": 5, "adjustAdjoining": true})); + assert_eq!(points(&s, id), vec![(0.0, 2.0, 1.0), (0.5, 2.0, 1.0), (1.0, 2.0, 1.0)]); + // Without it only the point changes. + set(&mut s, json!({"id": id.0, "index": 1, "t": 0.5, "left": 20, "right": 5})); + assert_eq!(points(&s, id), vec![(0.0, 2.0, 1.0), (0.5, 4.0, 1.0), (1.0, 2.0, 1.0)]); + s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [0, 2]})).unwrap(); + assert_eq!(points(&s, id), vec![(0.5, 4.0, 1.0)]); +} + +#[test] +fn flipping_along_swaps_the_sides_of_a_discontinuous_point() { + let (mut s, id) = line_session(); + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 1, 1], [0.25, 1, 1], [0.25, 2, 2], [1, 2, 2]]})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("stroke.setAdvanced", &json!({"flipProfile": "along"})).unwrap(); + assert_eq!(points(&s, id), vec![(0.0, 2.0, 2.0), (0.75, 2.0, 2.0), (0.75, 1.0, 1.0), (1.0, 1.0, 1.0)]); +} diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 01a98ae8..4d51a255 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -3,9 +3,15 @@ //! 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. Delete/Backspace removes the selected width -//! point. Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in -//! points) and commit on release; Delete runs `stroke.widthPoint.remove {id, index}`. +//! 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}`. use serde_json::json; use vectorcraft_doc::{Document, NodeId, NodeKind, StrokeLayer}; @@ -15,6 +21,10 @@ 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 { @@ -36,24 +46,55 @@ enum Mode { Width(Option), /// 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, + /// 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, mode: Mode, left: f64, right: f64, t: f64, + group: Option, + /// The other width points of the subpath when the drag began, which a slid point snaps onto. + others: Vec, + moved: bool, } #[derive(Default)] pub struct WidthTool { hover: Option, drag: Option, - /// Selected width point: (path, position t). - selected: Option<(NodeId, f64)>, + /// Selected width points: the path and their positions t. + selected: Option<(NodeId, Vec)>, } fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> { @@ -101,8 +142,8 @@ impl WidthTool { best_of(&mut all.into_iter().rev()).map(|b| b.1) } - /// Width points of `id`: (index, t, on-path point, left normal, left, right). - fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec<(usize, f64, Point, Vec2, f64, f64)> { + /// Width points of `id` on subpath `sub`. + fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec { let Some(st) = stroke_of(cx.doc, id) 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![] }; @@ -110,19 +151,98 @@ impl WidthTool { pr.points .iter() .enumerate() - .filter_map(|(i, (t, l, r))| { + .filter_map(|(index, (t, l, r))| { let (p, tan) = eval_fraction(sp, *t)?; - Some((i, *t, p, left_normal(tan), l * st.width / 2.0, r * st.width / 2.0)) + Some(WPoint { index, t: *t, p, n: left_normal(tan), left: l * st.width / 2.0, right: r * st.width / 2.0 }) }) .collect() } - fn params(d: &Drag) -> serde_json::Value { - let mut v = json!({"id": d.spot.id.0, "t": d.t, "left": d.left, "right": d.right}); - if let Some(i) = d.index { - v["index"] = json!(i); + /// 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)> { + let tol = cx.tol(5.0); + Self::points_of(cx, 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 { + 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 { + 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.id.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) + } } - v } } @@ -137,43 +257,52 @@ impl Tool for WidthTool { 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.id, vec![w.t])); + vec![Action::Dialog("widthPoint".into(), json!({"id": spot.id.0, "index": w.index}))] + } PointerKind::Down => { let Some(spot) = Self::spot_at(cx, p) else { - self.selected = None; + if !ev.mods.shift { + self.selected = None; + } return vec![]; }; - let tol = cx.tol(5.0); - // An existing width point under the pointer: its centre (move) or a handle end (width). - let mut hit = None; - for (i, t, q, n, l, r) in Self::points_of(cx, spot.id, spot.sub) { - if q.distance(p) <= tol { - hit = Some((i, t, q, n, l, r, Mode::Move)); - break; - } - if (q + n * l).distance(p) <= tol { - hit = Some((i, t, q, n, l, r, Mode::Width(ev.mods.alt.then_some(true)))); - break; - } - if (q - n * r).distance(p) <= tol { - hit = Some((i, t, q, n, l, r, Mode::Width(ev.mods.alt.then_some(false)))); - break; - } - } - let drag = match hit { - Some((i, t, q, n, l, r, mode)) => { - Drag { spot: Spot { t, p: q, n, left: l, right: r, ..spot }, index: Some(i), mode, left: l, right: r, t } + 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.id, w.t) { + return vec![]; + } + } else if mode == Mode::Copy || !self.is_selected(spot.id, w.t) { + self.selected = Some((spot.id, 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 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; - Drag { spot, index: None, mode: Mode::Width(ev.mods.alt.then_some(side)), left: spot.left, right: spot.right, t: spot.t } + self.selected = Some((spot.id, 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 } } }; - self.selected = Some((spot.id, drag.t)); + let label = if drag.mode == Mode::Copy { "Copy Width Point" } else { "Width Point" }; self.drag = Some(drag); - vec![Action::Begin("Width Point".into())] + 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); @@ -186,62 +315,72 @@ impl Tool for WidthTool { Some(false) => d.right = (-off).max(0.0), } } - Mode::Move => { - if let Some(NodeKind::Path { path, .. }) = cx.doc.node(d.spot.id).map(|n| &n.kind) - && let Some(sp) = path.subpaths.get(d.spot.sub) + 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) { - let one = vectorcraft_geom::PathData::single(sp.clone()); - if let Some((_, t, q, tan, _)) = nearest_fraction(&one, p) { - d.t = t; - d.spot.p = q; - d.spot.n = left_normal(tan); - } + (d.t, d.spot.p, d.spot.n) = (o.t, o.p, o.n); } } } - let v = Self::params(d); - vec![Action::Preview("stroke.widthPoint.set".into(), v)] + vec![Self::preview(d)] } PointerKind::Up => match self.drag.take() { Some(d) => { - self.selected = Some((d.spot.id, d.t)); + 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])), + // 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])), + }; vec![Action::Commit] } None => vec![], }, - _ => vec![], } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if !matches!(key, ToolKey::Delete | ToolKey::Backspace) { return vec![]; } - let Some((id, t)) = self.selected else { return vec![] }; - let Some(pr) = stroke_of(cx.doc, id).and_then(|s| s.profile.as_ref()) else { return vec![] }; - let Some((index, _)) = pr.points.iter().enumerate().min_by(|a, b| (a.1.0 - t).abs().total_cmp(&(b.1.0 - t).abs())) else { 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, "index": index}))] + vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "indices": indices}))] } fn overlays(&self, cx: &ToolContext) -> Vec { let mut out = vec![]; let r = cx.tol(4.0); - let focus = self.drag.map(|d| d.spot).or(self.hover); + let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover); let show = |id: NodeId, sub: usize, out: &mut Vec| { - for (_, t, q, n, l, rr) in Self::points_of(cx, id, sub) { - let sel = self.selected.is_some_and(|(sid, st)| sid == id && (st - t).abs() < 1e-6); - out.push(Overlay::Line { a: q + n * l, b: q - n * rr, color: BLUE, dashed: false }); - out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false }); - out.push(Overlay::Handle { p: q + n * l, color: BLUE }); - out.push(Overlay::Handle { p: q - n * rr, color: BLUE }); + for w in Self::points_of(cx, id, sub) { + let sel = self.is_selected(id, 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.id, s.sub, &mut out); } - if let Some((id, _)) = self.selected - && focus.is_none_or(|s| s.id != id) + if let Some((id, _)) = &self.selected + && focus.is_none_or(|s| s.id != *id) { - show(id, 0, &mut out); + show(*id, 0, &mut out); } if let Some(d) = &self.drag { let q = d.spot.p; @@ -284,6 +423,26 @@ mod tests { (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(); @@ -292,7 +451,7 @@ mod tests { 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 Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; + 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); @@ -312,17 +471,90 @@ mod tests { 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 Action::Preview(_, v) = &acts[0] else { panic!() }; + 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 Action::Preview(_, v) = &acts[0] else { panic!() }; + 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)); - let del = t.key(&c, ToolKey::Delete, Mods::default()); - assert!(matches!(&del[0], Action::Exec(c, _) if c == "stroke.widthPoint.remove")); + 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 = 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))); + } + + #[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"); } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 59479a57..462cd177 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -40,6 +40,7 @@ pub mod tile_edge_color; mod tools; mod transform; pub mod transform_each; +pub mod width_point; use serde_json::{Value, json}; @@ -168,6 +169,7 @@ registry! { Expand: [expand::KIND] => expand::SPEC, SpotColors: [spot_colors::KIND] => spot_colors::SPEC, TransformEach: [transform_each::KIND] => transform_each::SPEC, + WidthPoint: [width_point::KIND] => width_point::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index b65f431e..1d74481e 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -49,6 +49,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { } // Double-clicking a stop on the gradient annotator: its popover, next to the stop's chip. "gradientStop" => Dialog::new(kind, json!({"index": p.get("index").cloned().unwrap_or(json!(0)), "x": x, "y": y, "tab": "color"})), + // Double-clicking a width point with the Width tool: Width Point Edit. + super::width_point::KIND => { + if let Err(e) = super::width_point::open(app, &p) { + app.status(e); + } + return; + } _ => return, }; app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/dialogs/width_point.rs b/crates/ui-egui/src/dialogs/width_point.rs new file mode 100644 index 00000000..35a4ebb2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/width_point.rs @@ -0,0 +1,187 @@ +//! Width Point Edit: double-clicking a width point with the Width tool (or `ui.widthPointEdit`) +//! edits its side widths. Total Width changes both sides in proportion, the link keeps them equal, +//! and Adjust Adjoining Width Points changes the nearest points either side in proportion too. +//! OK runs `stroke.widthPoint.set`; Delete removes the point (`stroke.widthPoint.remove`). +//! +//! Fields: `id`, `index` (the width point), `t` (its place), `side1`, `side2` (the left and right +//! widths, points), `linked`, `adjustAdjoining`; Delete sets `discard: true` and confirms. + +use egui::vec2; +use serde_json::{Value, json}; +use vectorcraft_engine::doc::NodeId; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Width Point Edit. +pub const KIND: &str = "widthPoint"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Width Point Edit".into(), body, confirm, discard: Some("Delete"), min_width: 300.0, ..DialogSpec::FORM }; + +/// Width point `index` of `id`: its place and side widths (points). +fn point(app: &VectorcraftApp, id: u64, index: usize) -> Option<(f64, f64, f64)> { + let st = app.session.active()?.doc.node(NodeId(id))?.appearance.stroke()?; + let half = st.width / 2.0; + st.profile.as_ref()?.points.get(index).map(|&(t, l, r)| (t, l * half, r * half)) +} + +/// Open Width Point Edit on width point `index` of path `id` (`{id, index}`), filled in with its +/// widths. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let (id, index) = (p.get("id").and_then(Value::as_u64), p.get("index").and_then(Value::as_u64)); + let (Some(id), Some(index)) = (id, index) else { return Err("give the path `id` and the width point `index`".into()) }; + let (t, l, r) = point(app, id, index as usize).ok_or("no such width point")?; + let fields = json!({"id": id, "index": index, "t": t, "side1": l, "side2": r, "linked": (l - r).abs() < 1e-9, "adjustAdjoining": false}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.stroke_unit(); + let (s1, s2, linked) = (d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")); + let mut sides = None; + grid(ui, |ui| { + label(ui, "Side 1:"); + ui.horizontal(|ui| { + if let Some(v) = widgets::num_field(ui, "wp-side1", Some(s1), unit, 90.0) { + sides = Some((v, if linked { v } else { s2 })); + } + if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, "Keep both sides the same width", linked, 22.0).clicked() { + d.fields.insert("linked".into(), json!(!linked)); + if !linked { + sides = Some((s1, s1)); + } + } + }); + ui.end_row(); + label(ui, "Side 2:"); + if let Some(v) = widgets::num_field(ui, "wp-side2", Some(s2), unit, 90.0) { + sides = Some((if linked { v } else { s1 }, v)); + } + ui.end_row(); + label(ui, "Total Width:"); + if let Some(v) = widgets::num_field(ui, "wp-total", Some(s1 + s2), unit, 90.0) { + // Both sides in proportion (equal halves from nothing). + let k = if s1 + s2 > 1e-9 { v / (s1 + s2) } else { 0.0 }; + sides = Some(if k > 0.0 { (s1 * k, s2 * k) } else { (v / 2.0, v / 2.0) }); + } + ui.end_row(); + }); + if let Some((a, b)) = sides { + d.fields.insert("side1".into(), json!(a.max(0.0))); + d.fields.insert("side2".into(), json!(b.max(0.0))); + } + ui.add_space(4.0); + ui.allocate_ui(vec2(ui.available_width(), 22.0), |ui| { + if widgets::check(ui, "Adjust Adjoining Width Points", d.bool("adjustAdjoining"), true) { + d.fields.insert("adjustAdjoining".into(), json!(!d.bool("adjustAdjoining"))); + } + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (id, index) = (d.fields.get("id").cloned().unwrap_or_default(), d.fields.get("index").cloned().unwrap_or_default()); + if d.bool("discard") { + return run_and_close(app, "stroke.widthPoint.remove", json!({"id": id, "index": index})); + } + let (left, right) = (d.f64("side1", 0.0), d.f64("side2", 0.0)); + let p = json!({"id": id, "index": index, "t": d.f64("t", 0.0), "left": left, "right": right, "adjustAdjoining": d.bool("adjustAdjoining")}); + run_and_close(app, "stroke.widthPoint.set", p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + /// A 10 pt line from (100, 150) to (300, 150) with a 4 + 4 pt width point in the middle. + fn app() -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.line", json!({"x1": 100, "y1": 150, "x2": 300, "y2": 150})).unwrap()["id"].as_u64().unwrap(); + app.run("stroke.set", json!({"ids": [id], "weight": 10})).unwrap(); + app.run("stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 4, "right": 4})).unwrap(); + (app, id) + } + + fn points(app: &VectorcraftApp, id: u64) -> Vec<(f64, f64, f64)> { + let st = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke().unwrap().clone(); + let round = |v: f64| (v * 1e9).round() / 1e9; + st.profile.map(|p| p.points.into_iter().map(|(t, l, r)| (round(t), round(l), round(r))).collect()).unwrap_or_default() + } + + fn frame(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // A new window lays itself out in its first frame and paints in the next. + ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear(); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + #[test] + fn double_click_opens_it_and_ok_sets_the_widths() { + let (mut app, id) = app(); + let view = app.view_info(); + app.select_tool("width"); + let ev = PointerEvent::new(PointerKind::DoubleClick, 200.0, 150.0); + crate::canvas::dispatch(&mut app, &ev, view); + let d = app.ui.dialog.clone().expect("Width Point Edit opened"); + assert_eq!((d.kind.as_str(), d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")), (KIND, 4.0, 4.0, true)); + let shown = frame(&mut app); + for s in ["Width Point Edit", "Side 1:", "Side 2:", "Total Width:", "Adjust Adjoining Width Points", "Delete"] { + assert!(shown.iter().any(|t| t == s), "{s} in {shown:?}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("side1".into(), json!(6)); + d.fields.insert("side2".into(), json!(2)); + d.fields.insert("adjustAdjoining".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + // Side 1 is 6 pt (factor 1.2 of the 5 pt half weight), and the ends scaled with it. + assert_eq!(points(&app, id), vec![(0.0, 1.5, 0.5), (0.5, 1.2, 0.4), (1.0, 1.5, 0.5)]); + } + + #[test] + fn delete_removes_the_point() { + let (mut app, id) = app(); + app.run("ui.widthPointEdit", json!({"id": id, "index": 1})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(points(&app, id).len(), 2); + assert!(app.run("ui.widthPointEdit", json!({"id": id, "index": 9})).is_err()); + } + + #[test] + fn it_does_not_stretch_to_the_screen() { + let height = |screen_h: f32| { + let (mut app, id) = app(); + app.run("ui.widthPointEdit", json!({"id": id, "index": 1})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, screen_h)); + for _ in 0..4 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| super::super::show(&mut app, ui.ctx())).textures_delta.clear(); + } + ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", KIND)))).unwrap().height() + }; + let (short, tall) = (height(1000.0), height(2000.0)); + assert!((short - tall).abs() < 1.0 && short < 400.0, "{short} vs {tall}"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 627fda65..2685173f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -338,6 +338,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates)", ), + ( + "ui.widthPointEdit", + "Width Point Edit…", + "", + "{id, index} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -707,6 +713,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), }, + "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), _ => return None, }; Some(r) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5cc8eb46..b96f1ee0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -175,3 +175,8 @@ panel's checkbox set the same preferences through `prefs.set`. Use Preview Bounds: the Align panel flyout's Use Preview Bounds item shows and toggles the `usePreviewBounds` preference (`prefs.set`); while it is on, the bounding box and the Transform panel, Properties and Control bar fields measure visual bounds. + +Width Point Edit: double-clicking a width point with the Width tool, or `ui.widthPointEdit {id, index}`, opens the +`widthPoint` dialog (fields `id`, `index`, `t`, `side1` and `side2`: the left and right widths in points, `linked`, +`adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard", +value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`. diff --git a/docs/mcp.md b/docs/mcp.md index 6f988644..71f76d52 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -660,3 +660,19 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets ```json {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","bounds":"preview"}}} ``` + +## Width points + +`stroke.widthPoint.set {id, t, left, right, index?, adjustAdjoining?}` adds a width point (side widths in points) or, +with `index`, edits or moves one; `adjustAdjoining` changes the nearest points either side in proportion. Moving a +point onto another one's `t` (or `stroke.widthPoint.copy {id, index, t}` landing there) makes a discontinuous point: +two points at the same `t` (the width before it, then after it), and the stroke's width steps there. +`document.inspect` reports a path's `strokeOptions.widthPoints` (`[t, left, right]`, factors of half the weight; null +for a uniform stroke). `stroke.widthPoint.remove {id, index | indices}` deletes points; `stroke.widthProfile.set {ids, +points}` replaces them all, as dragging several Shift-selected points with the Width tool does in one undo step. + +```json +{"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.5,"left":4,"right":4}}} +{"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.8,"left":12,"right":12}}} +{"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"index":1,"t":0.8,"left":4,"right":4}}} +```