mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
d9740931a5
commit
b79262d4f4
14 files changed
+842
-101
No files matched your search
@@ -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
|
||||
|
||||
@@ -85,7 +85,8 @@ fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>, Vec<bool>)
|
||||
}
|
||||
|
||||
/// 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<Point>, Vec<bool>) {
|
||||
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<f64> = 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();
|
||||
|
||||
@@ -24,11 +24,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Option<usize>> {
|
||||
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<usize> = (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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize> = 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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ pub fn node_stroke(n: &Node) -> Option<StrokeLayer> {
|
||||
}
|
||||
|
||||
/// 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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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<f64> = 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)]);
|
||||
}
|
||||
@@ -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<bool>),
|
||||
/// Slide along the path.
|
||||
Move,
|
||||
/// Slide a copy along the path (Alt-drag the centre).
|
||||
Copy,
|
||||
}
|
||||
|
||||
/// A width point as the tool shows it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct WPoint {
|
||||
index: usize,
|
||||
t: f64,
|
||||
/// On-path point and left normal.
|
||||
p: Point,
|
||||
n: Vec2,
|
||||
/// Side widths (points).
|
||||
left: f64,
|
||||
right: f64,
|
||||
}
|
||||
|
||||
/// Several selected width points dragged together.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Group {
|
||||
/// The profile's points when the drag began.
|
||||
points: Vec<(f64, f64, f64)>,
|
||||
/// Which of them move.
|
||||
moving: Vec<usize>,
|
||||
/// The stroke's half weight.
|
||||
half: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Drag {
|
||||
/// Where the dragged point started (`t`, widths); `p` and `n` follow a slide.
|
||||
spot: Spot,
|
||||
index: Option<usize>,
|
||||
mode: Mode,
|
||||
left: f64,
|
||||
right: f64,
|
||||
t: f64,
|
||||
group: Option<Group>,
|
||||
/// The other width points of the subpath when the drag began, which a slid point snaps onto.
|
||||
others: Vec<WPoint>,
|
||||
moved: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WidthTool {
|
||||
hover: Option<Spot>,
|
||||
drag: Option<Drag>,
|
||||
/// Selected width point: (path, position t).
|
||||
selected: Option<(NodeId, f64)>,
|
||||
/// Selected width points: the path and their positions t.
|
||||
selected: Option<(NodeId, Vec<f64>)>,
|
||||
}
|
||||
|
||||
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<WPoint> {
|
||||
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<bool>)> {
|
||||
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<usize> {
|
||||
let Some(pr) = stroke_of(cx.doc, id).and_then(|s| s.profile.as_ref()) else { return vec![] };
|
||||
(0..pr.points.len()).filter(|i| self.is_selected(id, pr.points[*i].0)).collect()
|
||||
}
|
||||
|
||||
/// A drag of `id`'s selected points together: a move when more than one profile point is
|
||||
/// selected (a discontinuous point moves as one), a width change when several are.
|
||||
fn group(&self, cx: &ToolContext, id: NodeId, mode: Mode) -> Option<Group> {
|
||||
let st = stroke_of(cx.doc, id)?;
|
||||
let moving = self.selected_indices(cx, id);
|
||||
let several = match mode {
|
||||
Mode::Move => moving.len() > 1,
|
||||
Mode::Width(_) => self.selected.as_ref().is_some_and(|(_, ts)| ts.len() > 1),
|
||||
Mode::Copy => false,
|
||||
};
|
||||
several.then(|| Group { points: st.profile.as_ref().map(|p| p.points.clone()).unwrap_or_default(), moving, half: st.width / 2.0 })
|
||||
}
|
||||
|
||||
/// The command previewing drag `d`.
|
||||
fn preview(d: &Drag) -> Action {
|
||||
let id = d.spot.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<Action> {
|
||||
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<Overlay> {
|
||||
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<Overlay>| {
|
||||
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<f64> = v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap() * 1000.0).round() / 1000.0).collect();
|
||||
assert_eq!(ts, vec![0.0, 0.35, 0.5, 0.85, 1.0]);
|
||||
t.pointer(&c, &ev(PointerKind::Up, 170.0, 203.0, none));
|
||||
let (_, sel) = t.selected.clone().unwrap();
|
||||
assert!((sel[0] - 0.35).abs() < 1e-3 && (sel[1] - 0.85).abs() < 1e-3, "the selection moves with them: {sel:?}");
|
||||
// Widening one handle widens every selected point by as much.
|
||||
let mut t = WidthTool { selected: Some((id, vec![0.25, 0.75])), ..Default::default() };
|
||||
t.pointer(&c, &ev(PointerKind::Down, 250.0, 195.0, none));
|
||||
let acts = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 190.0, none));
|
||||
let pts = preview(&acts).1["points"].as_array().unwrap().clone();
|
||||
let w = |i: usize, side: usize| (pts[i][side].as_f64().unwrap() * 1e6).round() / 1e6;
|
||||
assert_eq!((w(1, 1), w(3, 2), w(2, 1)), (2.0, 2.0, 1.0));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 250.0, 190.0, none));
|
||||
// Shift-clicking a selected point takes it out again (and drags nothing).
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, shift)).is_empty());
|
||||
assert_eq!(t.selected_indices(&c, id), vec![3]);
|
||||
// A plain click on one of several selected points selects just that one.
|
||||
let mut t = WidthTool { selected: Some((id, vec![0.25, 0.75])), ..Default::default() };
|
||||
t.pointer(&c, &ev(PointerKind::Down, 250.0, 200.0, none));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 250.0, 200.0, none));
|
||||
assert_eq!(t.selected_indices(&c, id), vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_copies_and_dropping_onto_a_point_snaps_to_it() {
|
||||
let (d, id) = doc_three_points();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = WidthTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, alt)), vec![Action::Begin("Copy Width Point".into())]);
|
||||
let acts = t.pointer(&c, &ev(PointerKind::Drag, 180.0, 200.0, alt));
|
||||
let (cmd, v) = preview(&acts);
|
||||
assert_eq!((cmd, &v["id"], &v["index"]), ("stroke.widthPoint.copy", &json!(id.0), &json!(1)));
|
||||
assert!((v["t"].as_f64().unwrap() - 0.4).abs() < 1e-3);
|
||||
t.pointer(&c, &ev(PointerKind::Up, 180.0, 200.0, alt));
|
||||
// Sliding the point at 0.25 to within a few pixels of the one at 0.5 lands exactly on it.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 150.0, 200.0, Mods::default()));
|
||||
let acts = t.pointer(&c, &ev(PointerKind::Drag, 197.0, 200.0, Mods::default()));
|
||||
let (cmd, v) = preview(&acts);
|
||||
assert_eq!((cmd, v["t"].as_f64(), v["index"].as_u64()), ("stroke.widthPoint.set", Some(0.5), Some(1)));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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<R
|
||||
}
|
||||
None => 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)
|
||||
|
||||
@@ -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`.
|
||||
+16
@@ -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}}}
|
||||
```
|
||||
Reference in new issue
Block a user