mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
M4: variable width strokes (#58)
A stroke can carry a width profile: widths at positions along the path
(each subpath by length), interpolated smoothly (monotone cubic).
- doc: WidthProfile / WidthPoint / WidthPreset (uniform, wedge, taper both
ends, eye, rounded, pinch) on StrokeProps, serde-defaulted so older files
load; paint reach and hit testing follow the widest point.
- pathops: path measuring by length and the variable outline (offset along
the normals, round/miter/bevel joins, caps, dashes keep their width,
Inside/Outside clip as before; bounded samples and dashes, uniform
fallback). Canvas, PNG, SVG (filled outline), PDF and outline stroke use it.
- engine: stroke.widthProfile {preset | points}, node.set widthProfile,
inspect output; the Variable width tool (Shift+W) in vector edit mode:
drag the path to add a width point, slide points, drag their handles,
Delete removes the selected one; vector.edit.widthPoint add/set/remove.
Every gesture is one undo step.
- UI: width profile dropdown with previews drawn in code and a flip button
in the stroke settings; the tool in the edit toolbar (new icon drawn in
code); width handles and a hover preview on the canvas; Position/Width
fields for the selected width point.
- Tests: profile maths, outlines, render regressions, tool gestures,
commands, exports and junk sweeps. ROADMAP, README, docs updated.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,7 @@
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Vectors without compromise</h3>
|
||||
Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes and per-side strokes. Rectangles with smooth (squircle) corners, polygons, stars and arcs.
|
||||
Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path). Rectangles with smooth (squircle) corners, polygons, stars and arcs.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Paints and effects</h3>
|
||||
|
||||
+8
-5
@@ -43,8 +43,11 @@ dimension measured side by side.
|
||||
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
|
||||
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
|
||||
merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
|
||||
(⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept).
|
||||
*Left:* variable width strokes, transform box for several points, point snapping. 10–15 h.
|
||||
(⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept),
|
||||
variable width strokes (width profile presets with previews in the stroke settings, the Variable
|
||||
width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete;
|
||||
smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke).
|
||||
*Left:* transform box for several points, point snapping, one-sided widths. 7–11 h.
|
||||
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
|
||||
truncation/max lines, font picker with previews and recents, word/line selection, IME commit,
|
||||
inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the
|
||||
@@ -132,7 +135,7 @@ dimension measured side by side.
|
||||
### Where we're going
|
||||
|
||||
- **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers,
|
||||
a real-file corpus); variable width strokes (in progress); responsive presentation.
|
||||
a real-file corpus); responsive presentation.
|
||||
- **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8).
|
||||
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
|
||||
|
||||
@@ -183,9 +186,9 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Canvas & navigation | 6 | 72% | frame guides, 2x pixel preview | 3–5 |
|
||||
| Selection & transform | 8 | 68% | measure hover polish, scale tool on-canvas handles | 5–8 |
|
||||
| Layers panel | 5 | 79% | keyboard row navigation | 1–2 |
|
||||
| Shapes & vector | 8 | 63% | variable width, multi-point transform box, point snapping, live boolean editing, arc handles on canvas, shape builder on strokes and open paths | 10–15 |
|
||||
| Shapes & vector | 8 | 66% | multi-point transform box, point snapping, live boolean editing, arc handles on canvas, shape builder on strokes and open paths, one-sided widths | 7–11 |
|
||||
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
|
||||
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
|
||||
| Strokes | 3 | 76% | stroke styles, arrowhead previews in the menus | 2–3 |
|
||||
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
|
||||
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |
|
||||
| Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 |
|
||||
|
||||
@@ -72,7 +72,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -
|
||||
return true;
|
||||
}
|
||||
// Near the outline.
|
||||
let reach = (n.stroke.weight / 2.0).max(tol_l);
|
||||
let reach = (n.stroke.max_weight() / 2.0).max(tol_l);
|
||||
if !path.bounding_box().inflate(reach, reach).contains(lp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -781,3 +781,5 @@ mod colors_tests;
|
||||
mod diff_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
@@ -513,13 +513,14 @@ impl Node {
|
||||
/// Maximum distance (in local units) painting can reach outside the node box.
|
||||
pub fn paint_reach(&self) -> f64 {
|
||||
let stroke = if self.has_visible_stroke() {
|
||||
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or(self.stroke.weight);
|
||||
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight());
|
||||
let base = match self.stroke.align {
|
||||
StrokeAlign::Inside => 0.0,
|
||||
StrokeAlign::Center => w / 2.0,
|
||||
StrokeAlign::Outside => w,
|
||||
};
|
||||
let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w * 3.0 } else { w };
|
||||
// Arrowheads are sized by the weight, whatever a width profile does.
|
||||
let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w.max(self.stroke.weight) * 3.0 } else { w };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * self.stroke.miter_limit.clamp(1.0, 4.0)
|
||||
} else {
|
||||
0.0
|
||||
|
||||
@@ -78,6 +78,9 @@ pub struct StrokeProps {
|
||||
pub miter_limit: f64,
|
||||
/// Dash pattern (dash, gap, …); empty = solid.
|
||||
pub dashes: Vec<f64>,
|
||||
/// Variable width: how the width changes along the path (`None` = uniform).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width_profile: Option<WidthProfile>,
|
||||
}
|
||||
|
||||
impl Default for StrokeProps {
|
||||
@@ -91,10 +94,226 @@ impl Default for StrokeProps {
|
||||
join: StrokeJoin::Miter,
|
||||
miter_limit: 4.0,
|
||||
dashes: Vec::new(),
|
||||
width_profile: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StrokeProps {
|
||||
/// The variable width profile, when there is one with points.
|
||||
pub fn profile(&self) -> Option<&WidthProfile> {
|
||||
self.width_profile.as_ref().filter(|p| !p.points.is_empty())
|
||||
}
|
||||
|
||||
/// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile).
|
||||
pub fn max_weight(&self) -> f64 {
|
||||
self.profile().map_or(self.weight, |p| self.weight * p.max_scale())
|
||||
}
|
||||
}
|
||||
|
||||
/// One point of a variable width profile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidthPoint {
|
||||
/// Position along the path by length: 0 at its start, 1 at its end (each subpath on its own).
|
||||
pub t: f64,
|
||||
/// The stroke's width here as a multiple of the stroke weight.
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl WidthPoint {
|
||||
pub const fn new(t: f64, scale: f64) -> Self {
|
||||
Self { t, scale }
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable width profile: stroke widths at positions along the path, interpolated smoothly
|
||||
/// (monotone cubic, so the width never overshoots its neighbouring points) and held flat before
|
||||
/// the first and after the last point.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WidthProfile {
|
||||
/// Sorted by `t` (see [`WidthProfile::sanitized`]).
|
||||
pub points: Vec<WidthPoint>,
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// The most points a profile keeps.
|
||||
pub const MAX_POINTS: usize = 64;
|
||||
/// The widest a point can be, as a multiple of the stroke weight.
|
||||
pub const MAX_SCALE: f64 = 1000.0;
|
||||
|
||||
/// A profile from `(t, scale)` pairs (sanitized).
|
||||
pub fn new(points: impl IntoIterator<Item = (f64, f64)>) -> Self {
|
||||
Self { points: points.into_iter().map(|(t, s)| WidthPoint::new(t, s)).collect() }.sanitized()
|
||||
}
|
||||
|
||||
/// Uniform width with a point at each end (where the width tool starts from).
|
||||
pub fn uniform() -> Self {
|
||||
Self::new([(0.0, 1.0), (1.0, 1.0)])
|
||||
}
|
||||
|
||||
/// Finite points only, `t` in 0..=1, scales in 0..=[`Self::MAX_SCALE`], sorted by `t`, at
|
||||
/// most [`Self::MAX_POINTS`].
|
||||
pub fn sanitized(&self) -> Self {
|
||||
let mut points: Vec<WidthPoint> = self
|
||||
.points
|
||||
.iter()
|
||||
.filter(|p| p.t.is_finite() && p.scale.is_finite())
|
||||
.take(Self::MAX_POINTS)
|
||||
.map(|p| WidthPoint::new(p.t.clamp(0.0, 1.0), p.scale.clamp(0.0, Self::MAX_SCALE)))
|
||||
.collect();
|
||||
points.sort_by(|a, b| a.t.total_cmp(&b.t));
|
||||
Self { points }
|
||||
}
|
||||
|
||||
/// The width scale at `t` (0..1 along the path).
|
||||
pub fn scale_at(&self, t: f64) -> f64 {
|
||||
let pts = &self.points;
|
||||
let (Some(first), Some(last)) = (pts.first(), pts.last()) else { return 1.0 };
|
||||
if !t.is_finite() || t <= first.t {
|
||||
return first.scale;
|
||||
}
|
||||
if t >= last.t {
|
||||
return last.scale;
|
||||
}
|
||||
let k = pts.partition_point(|p| p.t <= t).saturating_sub(1);
|
||||
let (Some(a), Some(b)) = (pts.get(k), pts.get(k + 1)) else { return last.scale };
|
||||
let h = b.t - a.t;
|
||||
if h <= 1e-12 {
|
||||
return b.scale;
|
||||
}
|
||||
let u = (t - a.t) / h;
|
||||
let (u2, u3) = (u * u, u * u * u);
|
||||
let w = (2.0 * u3 - 3.0 * u2 + 1.0) * a.scale
|
||||
+ (u3 - 2.0 * u2 + u) * h * self.tangent(k)
|
||||
+ (3.0 * u2 - 2.0 * u3) * b.scale
|
||||
+ (u3 - u2) * h * self.tangent(k + 1);
|
||||
w.max(0.0)
|
||||
}
|
||||
|
||||
/// Slope of the segment from point `i` to `i + 1` (0 when they share a position).
|
||||
fn secant(&self, i: usize) -> f64 {
|
||||
match (self.points.get(i), self.points.get(i + 1)) {
|
||||
(Some(a), Some(b)) if b.t - a.t > 1e-12 => (b.scale - a.scale) / (b.t - a.t),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fritsch–Carlson style tangent at point `i`: one-sided at the ends, the harmonic mean of the
|
||||
/// neighbouring slopes inside, flat at local extremes (keeps the curve monotone between points).
|
||||
fn tangent(&self, i: usize) -> f64 {
|
||||
let last = self.points.len().saturating_sub(1);
|
||||
if i == 0 {
|
||||
return self.secant(0);
|
||||
}
|
||||
if i >= last {
|
||||
return self.secant(last.saturating_sub(1));
|
||||
}
|
||||
let (d0, d1) = (self.secant(i - 1), self.secant(i));
|
||||
if d0 * d1 <= 0.0 { 0.0 } else { 2.0 * d0 * d1 / (d0 + d1) }
|
||||
}
|
||||
|
||||
/// The largest scale (the interpolation never exceeds its points).
|
||||
pub fn max_scale(&self) -> f64 {
|
||||
self.points.iter().map(|p| p.scale).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Add a point (kept sorted); returns its index.
|
||||
pub fn insert(&mut self, t: f64, scale: f64) -> usize {
|
||||
let p = WidthPoint::new(
|
||||
if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 },
|
||||
if scale.is_finite() { scale.clamp(0.0, Self::MAX_SCALE) } else { 1.0 },
|
||||
);
|
||||
let i = self.points.partition_point(|q| q.t <= p.t);
|
||||
self.points.insert(i, p);
|
||||
i
|
||||
}
|
||||
|
||||
/// Move point `i` along the path (kept between its neighbours, so indices stay put) and/or
|
||||
/// change its scale. Returns false when there is no such point.
|
||||
pub fn set_point(&mut self, i: usize, t: Option<f64>, scale: Option<f64>) -> bool {
|
||||
let lo = i.checked_sub(1).and_then(|j| self.points.get(j)).map_or(0.0, |p| p.t);
|
||||
let hi = self.points.get(i + 1).map_or(1.0, |p| p.t);
|
||||
let Some(p) = self.points.get_mut(i) else { return false };
|
||||
if let Some(t) = t.filter(|t| t.is_finite()) {
|
||||
p.t = t.clamp(lo, hi.max(lo));
|
||||
}
|
||||
if let Some(s) = scale.filter(|s| s.is_finite()) {
|
||||
p.scale = s.clamp(0.0, Self::MAX_SCALE);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The preset this profile is, if any.
|
||||
pub fn preset(&self) -> Option<WidthPreset> {
|
||||
WidthPreset::ALL.into_iter().find(|p| p.profile().as_ref() == Some(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// The width profiles offered in the stroke settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WidthPreset {
|
||||
Uniform,
|
||||
/// Full width at the start, a point at the end.
|
||||
Wedge,
|
||||
/// Pointed at both ends, full width in between.
|
||||
Taper,
|
||||
/// Widest in the middle, pointed at both ends.
|
||||
Eye,
|
||||
/// Full width with rounded-off ends.
|
||||
Rounded,
|
||||
/// Thin in the middle, full width at the ends.
|
||||
Pinch,
|
||||
}
|
||||
|
||||
impl WidthPreset {
|
||||
pub const ALL: [WidthPreset; 6] =
|
||||
[WidthPreset::Uniform, WidthPreset::Wedge, WidthPreset::Taper, WidthPreset::Eye, WidthPreset::Rounded, WidthPreset::Pinch];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
WidthPreset::Uniform => "uniform",
|
||||
WidthPreset::Wedge => "wedge",
|
||||
WidthPreset::Taper => "taper",
|
||||
WidthPreset::Eye => "eye",
|
||||
WidthPreset::Rounded => "rounded",
|
||||
WidthPreset::Pinch => "pinch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
WidthPreset::Uniform => "Uniform",
|
||||
WidthPreset::Wedge => "Wedge",
|
||||
WidthPreset::Taper => "Taper both ends",
|
||||
WidthPreset::Eye => "Eye",
|
||||
WidthPreset::Rounded => "Rounded",
|
||||
WidthPreset::Pinch => "Pinch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<WidthPreset> {
|
||||
let s: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
|
||||
WidthPreset::ALL
|
||||
.into_iter()
|
||||
.find(|p| p.id().eq_ignore_ascii_case(&s) || p.label().replace(' ', "").eq_ignore_ascii_case(&s))
|
||||
.or_else(|| s.eq_ignore_ascii_case("bulge").then_some(WidthPreset::Eye))
|
||||
}
|
||||
|
||||
/// The preset's profile (`None` = uniform).
|
||||
pub fn profile(self) -> Option<WidthProfile> {
|
||||
let pts: &[(f64, f64)] = match self {
|
||||
WidthPreset::Uniform => return None,
|
||||
WidthPreset::Wedge => &[(0.0, 1.0), (1.0, 0.0)],
|
||||
WidthPreset::Taper => &[(0.0, 0.0), (0.2, 1.0), (0.8, 1.0), (1.0, 0.0)],
|
||||
WidthPreset::Eye => &[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)],
|
||||
WidthPreset::Rounded => &[(0.0, 0.3), (0.05, 0.8), (0.15, 1.0), (0.85, 1.0), (0.95, 0.8), (1.0, 0.3)],
|
||||
WidthPreset::Pinch => &[(0.0, 1.0), (0.5, 0.25), (1.0, 1.0)],
|
||||
};
|
||||
Some(WidthProfile::new(pts.iter().copied()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A shadow (drop or inner).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Variable width profiles: interpolation, editing, presets and loading older files.
|
||||
|
||||
use crate::{StrokeProps, WidthPoint, WidthPreset, WidthProfile};
|
||||
|
||||
#[test]
|
||||
fn interpolates_smoothly_without_overshoot() {
|
||||
let eye = WidthPreset::Eye.profile().unwrap();
|
||||
assert_eq!(eye.scale_at(0.0), 0.0);
|
||||
assert_eq!(eye.scale_at(0.5), 1.0);
|
||||
assert_eq!(eye.scale_at(1.0), 0.0);
|
||||
// Symmetric, rising then falling, never above the middle point.
|
||||
let a = eye.scale_at(0.25);
|
||||
assert!((a - eye.scale_at(0.75)).abs() < 1e-12);
|
||||
assert!(a > 0.5 && a < 1.0, "{a}");
|
||||
for i in 0..=100 {
|
||||
let s = eye.scale_at(i as f64 / 100.0);
|
||||
assert!((0.0..=1.0).contains(&s), "{s}");
|
||||
}
|
||||
// Two points interpolate linearly; outside the points the ends hold.
|
||||
let wedge = WidthPreset::Wedge.profile().unwrap();
|
||||
assert!((wedge.scale_at(0.3) - 0.7).abs() < 1e-12);
|
||||
let held = WidthProfile::new([(0.25, 2.0), (0.75, 4.0)]);
|
||||
assert_eq!(held.scale_at(0.0), 2.0);
|
||||
assert_eq!(held.scale_at(1.0), 4.0);
|
||||
assert_eq!(held.max_scale(), 4.0);
|
||||
// Monotone between points of a rising profile.
|
||||
let rise = WidthProfile::new([(0.0, 0.0), (0.1, 3.0), (0.2, 3.1), (1.0, 8.0)]);
|
||||
let mut prev = -1.0;
|
||||
for i in 0..=200 {
|
||||
let s = rise.scale_at(i as f64 / 200.0);
|
||||
assert!(s >= prev - 1e-12, "monotone: {s} after {prev}");
|
||||
prev = s;
|
||||
}
|
||||
// Empty is uniform; a single point holds everywhere.
|
||||
assert_eq!(WidthProfile::default().scale_at(0.4), 1.0);
|
||||
assert_eq!(WidthProfile::new([(0.5, 3.0)]).scale_at(0.1), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_hostile_points() {
|
||||
let p = WidthProfile {
|
||||
points: vec![
|
||||
WidthPoint::new(f64::NAN, 1.0),
|
||||
WidthPoint::new(2.0, -5.0),
|
||||
WidthPoint::new(-1.0, 1e9),
|
||||
WidthPoint::new(0.5, f64::INFINITY),
|
||||
WidthPoint::new(0.5, 0.5),
|
||||
WidthPoint::new(0.5, 0.7),
|
||||
],
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(
|
||||
p.points,
|
||||
vec![WidthPoint::new(0.0, WidthProfile::MAX_SCALE), WidthPoint::new(0.5, 0.5), WidthPoint::new(0.5, 0.7), WidthPoint::new(1.0, 0.0)]
|
||||
);
|
||||
// Coincident points don't divide by zero.
|
||||
assert!(p.scale_at(0.5).is_finite());
|
||||
assert!(p.scale_at(f64::NAN).is_finite());
|
||||
let many = WidthProfile::new((0..500).map(|i| (i as f64 / 500.0, 1.0)));
|
||||
assert_eq!(many.points.len(), WidthProfile::MAX_POINTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_keep_points_in_order() {
|
||||
let mut p = WidthProfile::uniform();
|
||||
let i = p.insert(0.4, 3.0);
|
||||
assert_eq!(i, 1);
|
||||
assert_eq!(p.points.len(), 3);
|
||||
// Moving is limited by the neighbours, so indices stay put.
|
||||
assert!(p.set_point(1, Some(1.5), None));
|
||||
assert_eq!(p.points[1].t, 1.0);
|
||||
assert!(p.set_point(1, Some(0.3), Some(-2.0)));
|
||||
assert_eq!(p.points[1], WidthPoint::new(0.3, 0.0));
|
||||
assert!(!p.set_point(9, Some(0.5), None));
|
||||
let i = p.insert(f64::NAN, f64::NAN);
|
||||
assert_eq!(p.points[i], WidthPoint::new(0.0, 1.0), "junk lands at the start at full width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_round_trip() {
|
||||
for p in WidthPreset::ALL {
|
||||
assert_eq!(WidthPreset::from_id(p.id()), Some(p));
|
||||
assert_eq!(WidthPreset::from_id(p.label()), Some(p));
|
||||
if let Some(prof) = p.profile() {
|
||||
assert_eq!(prof.preset(), Some(p));
|
||||
assert!(prof.points.windows(2).all(|w| w[0].t <= w[1].t));
|
||||
}
|
||||
}
|
||||
assert_eq!(WidthPreset::from_id("Taper both ends"), Some(WidthPreset::Taper));
|
||||
assert_eq!(WidthPreset::from_id("nope"), None);
|
||||
assert_eq!(WidthProfile::new([(0.0, 1.0), (0.3, 2.0)]).preset(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_strokes_load_and_uniform_strokes_save_as_before() {
|
||||
let old: StrokeProps = serde_json::from_str(r#"{"weight": 3, "align": "center"}"#).unwrap();
|
||||
assert!(old.width_profile.is_none());
|
||||
assert_eq!(old.max_weight(), 3.0);
|
||||
let json = serde_json::to_string(&StrokeProps::default()).unwrap();
|
||||
assert!(!json.contains("width_profile"), "{json}");
|
||||
let s = StrokeProps { weight: 2.0, width_profile: WidthPreset::Taper.profile(), ..Default::default() };
|
||||
let back: StrokeProps = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
assert_eq!(back.max_weight(), 2.0);
|
||||
// A profile without points is no profile.
|
||||
let empty = StrokeProps { width_profile: Some(WidthProfile::default()), ..Default::default() };
|
||||
assert!(empty.profile().is_none());
|
||||
}
|
||||
@@ -191,6 +191,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
|
||||
spec!(
|
||||
"stroke.widthProfile",
|
||||
"Width profile",
|
||||
"{ids?, preset? (uniform|wedge|taper|eye|rounded|pinch) | points? [[t (0-1 along each subpath), scale (× stroke weight)], ...]} — none = uniform",
|
||||
true,
|
||||
width_profile
|
||||
),
|
||||
spec!(
|
||||
"effect.add",
|
||||
"Add effect",
|
||||
@@ -931,6 +938,20 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances
|
||||
/// record the override).
|
||||
fn width_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "stroke.widthProfile";
|
||||
let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null);
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]);
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
super::props::apply(cmd, doc, i, &props)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("node.reparent", s, p)?;
|
||||
let parent = req_id("node.reparent", p, "parent")?;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{Result, bad};
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \
|
||||
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \
|
||||
strokeCap / startCap / endCap, strokeJoin, dashes [..], cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap, \
|
||||
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
|
||||
@@ -117,6 +117,30 @@ fn four(v: &Value) -> Option<[f64; 4]> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)], ...] or [{t, scale}], {preset} | {points}, or null (uniform)";
|
||||
|
||||
/// A width profile from a preset name, points (`[[t, scale], ...]` or `[{t, scale}]`), `{preset}`
|
||||
/// / `{points}`, or null. `Some(None)` = uniform; `None` = not a profile.
|
||||
pub fn width_profile_of(v: &Value) -> Option<Option<WidthProfile>> {
|
||||
match v {
|
||||
Value::Null => Some(None),
|
||||
Value::String(s) => WidthPreset::from_id(s).map(WidthPreset::profile),
|
||||
Value::Array(a) => {
|
||||
let pts = a
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Value::Array(xy) => Some((xy.first().and_then(f64_of)?, xy.get(1).and_then(f64_of)?)),
|
||||
Value::Object(o) => Some((o.get("t").and_then(f64_of)?, o.get("scale").or_else(|| o.get("width")).and_then(f64_of)?)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Some(WidthProfile::new(pts)).filter(|p| !p.points.is_empty()))
|
||||
}
|
||||
Value::Object(o) => o.get("points").or_else(|| o.get("preset")).and_then(width_profile_of),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
|
||||
|
||||
/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
|
||||
@@ -274,6 +298,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
|
||||
doc.get_mut(id)?.stroke.dashes = d;
|
||||
}
|
||||
"widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?,
|
||||
"cornerRadius" => {
|
||||
let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?;
|
||||
doc.get_mut(id)?.corners.radii = r.map(|x| x.max(0.0));
|
||||
@@ -614,9 +639,8 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
pub fn override_group(k: &str) -> &'static str {
|
||||
match k {
|
||||
"fills" | "fill" => "fills",
|
||||
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" => {
|
||||
"strokes"
|
||||
}
|
||||
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
|
||||
| "widthProfile" => "strokes",
|
||||
"effects" => "effects",
|
||||
"opacity" | "blend" | "blendMode" => "opacity",
|
||||
"visible" => "visible",
|
||||
|
||||
@@ -52,6 +52,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points: Vec<[f64; 2]> = p.points.iter().map(|q| [q.t, q.scale]).collect();
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
|
||||
@@ -228,6 +228,8 @@ pub struct Overlay {
|
||||
/// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the
|
||||
/// paint bucket would fill (page-space polygons).
|
||||
pub vector_hover: Option<crate::vectoredit::VHit>,
|
||||
/// Vector edit mode, Variable width tool: the width point, handle or path under the pointer.
|
||||
pub width_hover: Option<crate::vectoredit::WHit>,
|
||||
pub lasso: Vec<[f64; 2]>,
|
||||
/// The eraser brush under the pointer (x, y, radius in page units).
|
||||
pub brush: Option<[f64; 3]>,
|
||||
|
||||
@@ -9,6 +9,9 @@ pub mod ops;
|
||||
mod pointer;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod width;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
@@ -20,6 +23,7 @@ use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub(crate) use pointer::VGesture;
|
||||
pub use width::{WHit, WidthHandle, width_handles, width_hit, width_preview};
|
||||
|
||||
/// The edit-mode tools (the toolbar shown while editing a vector).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)]
|
||||
@@ -32,10 +36,12 @@ pub enum VTool {
|
||||
Lasso,
|
||||
Cut,
|
||||
Eraser,
|
||||
/// Variable width: width points along the stroke.
|
||||
Width,
|
||||
}
|
||||
|
||||
impl VTool {
|
||||
pub const ALL: [VTool; 7] = [VTool::Move, VTool::Pen, VTool::Bend, VTool::Paint, VTool::Lasso, VTool::Cut, VTool::Eraser];
|
||||
pub const ALL: [VTool; 8] = [VTool::Move, VTool::Pen, VTool::Bend, VTool::Paint, VTool::Lasso, VTool::Cut, VTool::Eraser, VTool::Width];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
VTool::Move => "move",
|
||||
@@ -45,6 +51,7 @@ impl VTool {
|
||||
VTool::Lasso => "lasso",
|
||||
VTool::Cut => "cut",
|
||||
VTool::Eraser => "eraser",
|
||||
VTool::Width => "width",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<VTool> {
|
||||
@@ -59,6 +66,7 @@ impl VTool {
|
||||
VTool::Lasso => "Lasso",
|
||||
VTool::Cut => "Cut",
|
||||
VTool::Eraser => "Eraser",
|
||||
VTool::Width => "Variable width",
|
||||
}
|
||||
}
|
||||
/// Catalog-style shortcut ("Shift+B"); the Bend tool is held with Cmd (Ctrl elsewhere).
|
||||
@@ -71,6 +79,7 @@ impl VTool {
|
||||
VTool::Lasso => "Q",
|
||||
VTool::Cut => "X",
|
||||
VTool::Eraser => "Shift+E",
|
||||
VTool::Width => "Shift+W",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,11 +96,13 @@ pub struct VectorEdit {
|
||||
pub anchor: Option<usize>,
|
||||
/// Pen: the outgoing handle dragged out of `anchor`, used by the next segment.
|
||||
pub pen_out: Option<(f64, f64)>,
|
||||
/// Variable width tool: the selected width point (an index into the stroke's profile).
|
||||
pub width_point: Option<usize>,
|
||||
}
|
||||
|
||||
impl VectorEdit {
|
||||
pub fn new(id: NodeId) -> Self {
|
||||
Self { id, vertices: vec![], segments: vec![], tool: VTool::Move, anchor: None, pen_out: None }
|
||||
Self { id, vertices: vec![], segments: vec![], tool: VTool::Move, anchor: None, pen_out: None, width_point: None }
|
||||
}
|
||||
|
||||
/// Drop indices that no longer exist (after undo or an edit).
|
||||
@@ -463,6 +474,12 @@ pub fn key(s: &mut Session, name: &str, shift: bool, alt: bool, cmd: bool) -> Re
|
||||
end(s)?;
|
||||
}
|
||||
}
|
||||
("Backspace" | "Delete", false, false, false) if ve.tool == VTool::Width => {
|
||||
// The selected width point (nothing else is selected with the Variable width tool).
|
||||
if let Some(i) = ve.width_point {
|
||||
s.execute("vector.edit.widthPoint", &json!({"action": "remove", "index": i}))?;
|
||||
}
|
||||
}
|
||||
("Backspace" | "Delete", heal, false, false) => {
|
||||
s.execute(if heal { "vector.deleteHeal" } else { "vector.edit.delete" }, &json!({}))?;
|
||||
}
|
||||
@@ -478,6 +495,7 @@ pub fn key(s: &mut Session, name: &str, shift: bool, alt: bool, cmd: bool) -> Re
|
||||
("Q", false, false, false) => set_tool(s, VTool::Lasso)?,
|
||||
("X", false, false, false) => set_tool(s, VTool::Cut)?,
|
||||
("E", true, false, false) => set_tool(s, VTool::Eraser)?,
|
||||
("W", true, false, false) => set_tool(s, VTool::Width)?,
|
||||
_ => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
@@ -522,9 +540,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let o = overlay(doc, &ve).map(|o| serde_json::to_value(o).unwrap_or(Value::Null)).unwrap_or(Value::Null);
|
||||
let net = network(doc, ve.id).map(|n| serde_json::to_value(n).unwrap_or(Value::Null)).unwrap_or(Value::Null);
|
||||
let mirroring: Vec<&str> = network(doc, ve.id).map(|n| (0..n.vertices.len()).map(|v| n.mirroring(v).id()).collect()).unwrap_or_default();
|
||||
Ok(json!({"id": ve.id, "tool": ve.tool.id(), "overlay": o, "network": net, "mirroring": mirroring}))
|
||||
Ok(json!({"id": ve.id, "tool": ve.tool.id(), "overlay": o, "network": net, "mirroring": mirroring,
|
||||
"widthPoints": width_handles(doc, &ve, 1.0), "widthPoint": ve.width_point}))
|
||||
}),
|
||||
spec!("vector.edit.tool", "Vector edit tool", "{tool: move|pen|bend|paint|lasso|cut}", false, |s, p| {
|
||||
spec!("vector.edit.tool", "Vector edit tool", "{tool: move|pen|bend|paint|lasso|cut|eraser|width}", false, |s, p| {
|
||||
editing(s)?;
|
||||
let t = string(p, "tool").and_then(|t| VTool::from_id(&t)).ok_or_else(|| bad("vector.edit.tool", "unknown tool"))?;
|
||||
set_tool(s, t)?;
|
||||
@@ -778,6 +797,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"ok": true}))
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(width::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The paint bucket on node `id` at local point `lp`. A vector without fills gets the default
|
||||
|
||||
@@ -52,6 +52,8 @@ pub(crate) enum VGesture {
|
||||
stamps: Vec<Point>,
|
||||
radius: f64,
|
||||
},
|
||||
/// Variable width tool.
|
||||
Width(super::width::WidthGesture),
|
||||
}
|
||||
|
||||
/// Eraser brush radius in screen pixels.
|
||||
@@ -144,9 +146,11 @@ impl Session {
|
||||
fn vector_hover(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<()> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
let h = hit(&doc, ve, ev.p(), tol);
|
||||
let width_tool = ve.tool == VTool::Width;
|
||||
let h = if width_tool { None } else { hit(&doc, ve, ev.p(), tol) };
|
||||
let ov = &mut self.tool.overlay;
|
||||
ov.vector_hover = h;
|
||||
ov.width_hover = None;
|
||||
ov.face.clear();
|
||||
ov.rubber = None;
|
||||
ov.brush = (ve.tool == VTool::Eraser).then(|| [ev.x, ev.y, ERASER_PX / ev.zoom.max(1e-6)]);
|
||||
@@ -187,6 +191,7 @@ impl Session {
|
||||
self.tool.overlay.face = polys;
|
||||
}
|
||||
}
|
||||
VTool::Width => self.width_hover(ve, ev)?,
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
@@ -352,6 +357,7 @@ impl Session {
|
||||
})?;
|
||||
self.vgesture(VGesture::Erase { stamps, radius });
|
||||
}
|
||||
VTool::Width => self.width_down(ve, ev)?,
|
||||
VTool::Lasso => {
|
||||
let base = if ev.shift { ve.vertices.clone() } else { vec![] };
|
||||
self.tool.overlay.lasso = vec![[ev.x, ev.y]];
|
||||
@@ -572,6 +578,7 @@ impl Session {
|
||||
self.pen_state(plan, v, Some((d.x, d.y)))?;
|
||||
self.vgesture(VGesture::Pen { plan, origin, out });
|
||||
}
|
||||
VGesture::Width(g) => self.width_drag(ve, g, ev)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -603,6 +610,7 @@ impl Session {
|
||||
VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } => self.commit_interaction()?,
|
||||
VGesture::Marquee { .. } | VGesture::Lasso { .. } => {}
|
||||
VGesture::Erase { .. } => self.commit_interaction()?,
|
||||
VGesture::Width(g) => self.width_up(g)?,
|
||||
}
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! The Variable width tool (⇧W) in vector edit mode: width points on the stroke, each with a
|
||||
//! position along the path and a width. Dragging the path adds a point and sets its width,
|
||||
//! dragging a point slides it along the path, dragging one of its handles changes its width;
|
||||
//! Delete removes the selected point. Every gesture is one undo step.
|
||||
//!
|
||||
//! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are
|
||||
//! fractions of each subpath's length).
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Point, Vec2};
|
||||
use figcraft_pathops::width::{Polyline, nearest, polylines};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{editing, set_state};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::tools::{Gesture, PointerEvent};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Width handles sit at least this far from the path on screen, so thin points stay grabbable.
|
||||
const HANDLE_MIN_PX: f64 = 8.0;
|
||||
/// Pointer tolerance (screen pixels).
|
||||
const TOL_PX: f64 = 6.0;
|
||||
|
||||
/// What the pointer is over with the Variable width tool.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum WHit {
|
||||
/// A width point on the path (drag slides it along).
|
||||
Point { index: usize },
|
||||
/// One of a width point's handles (drag sets its width).
|
||||
Handle { index: usize },
|
||||
/// The path between points (drag adds a point there); `t` is the position along it.
|
||||
Path { t: f64 },
|
||||
}
|
||||
|
||||
/// A width point as drawn: on the path and its two handles, in page coordinates, per subpath.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WidthHandle {
|
||||
pub index: usize,
|
||||
pub at: [f64; 2],
|
||||
pub sides: [[f64; 2]; 2],
|
||||
/// The stroke's full width there (local units).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// An in-progress Variable width gesture.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum WidthGesture {
|
||||
/// Slide point `index` along the path.
|
||||
Slide { index: usize, moved: bool },
|
||||
/// Set the width of point `index`; `added` = the gesture added it at `t`.
|
||||
Size { index: usize, added: Option<f64>, moved: bool },
|
||||
}
|
||||
|
||||
/// The edited node's stroke path flattened (local coordinates) with its weight and profile.
|
||||
struct Stroked {
|
||||
lines: Vec<Polyline>,
|
||||
weight: f64,
|
||||
profile: Option<WidthProfile>,
|
||||
}
|
||||
|
||||
fn stroked(doc: &Document, id: NodeId) -> Option<Stroked> {
|
||||
let n = doc.node(id)?;
|
||||
let path = figcraft_doc::geometry::stroke_geometry(n)?;
|
||||
Some(Stroked { lines: polylines(&path)?, weight: n.stroke.weight, profile: n.stroke.profile().cloned() })
|
||||
}
|
||||
|
||||
/// Width handles for `(index, t, scale)` on every subpath, as drawn at `zoom` (page coordinates).
|
||||
fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (usize, f64, f64)> + Clone, zoom: f64) -> Vec<WidthHandle> {
|
||||
let w = doc.world_transform(id);
|
||||
let min = HANDLE_MIN_PX / zoom.max(1e-6);
|
||||
let mut out = Vec::new();
|
||||
for line in &st.lines {
|
||||
for (index, t, scale) in pts.clone() {
|
||||
let Some((at, n)) = line.at(t) else { continue };
|
||||
let half = 0.5 * st.weight * scale;
|
||||
let c = w * at;
|
||||
let dir = w * (at + n) - c;
|
||||
let dir = if dir.hypot() > 1e-12 { dir / dir.hypot() } else { Vec2::new(0.0, 1.0) };
|
||||
let reach = ((w * (at + n * half)) - c).hypot().max(min);
|
||||
let side = |s: f64| {
|
||||
let q = c + dir * (reach * s);
|
||||
[q.x, q.y]
|
||||
};
|
||||
out.push(WidthHandle { index, at: [c.x, c.y], sides: [side(1.0), side(-1.0)], width: 2.0 * half });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The width points of the vector being edited as drawn at `zoom` (page coordinates).
|
||||
pub fn width_handles(doc: &Document, ve: &super::VectorEdit, zoom: f64) -> Vec<WidthHandle> {
|
||||
let Some(st) = stroked(doc, ve.id) else { return vec![] };
|
||||
let Some(profile) = st.profile.clone() else { return vec![] };
|
||||
handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.scale)), zoom)
|
||||
}
|
||||
|
||||
/// Where a width point added at `t` would go, with the width there (the hover preview).
|
||||
pub fn width_preview(doc: &Document, ve: &super::VectorEdit, t: f64, zoom: f64) -> Vec<WidthHandle> {
|
||||
let Some(st) = stroked(doc, ve.id) else { return vec![] };
|
||||
let scale = st.profile.as_ref().map_or(1.0, |p| p.scale_at(t));
|
||||
handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, scale)), zoom)
|
||||
}
|
||||
|
||||
/// Hit test the Variable width tool at page point `p`: handles, then points, then the path.
|
||||
pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) -> Option<WHit> {
|
||||
let tol = TOL_PX / zoom.max(1e-6);
|
||||
let near = |q: [f64; 2]| (Point::new(q[0], q[1]) - p).hypot();
|
||||
let handles = width_handles(doc, ve, zoom);
|
||||
let best = |pick: &dyn Fn(&WidthHandle) -> f64| {
|
||||
handles.iter().map(|h| (h.index, pick(h))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
};
|
||||
if let Some(index) = best(&|h| near(h.sides[0]).min(near(h.sides[1]))) {
|
||||
return Some(WHit::Handle { index });
|
||||
}
|
||||
if let Some(index) = best(&|h| near(h.at)) {
|
||||
return Some(WHit::Point { index });
|
||||
}
|
||||
// Anywhere on the painted stroke counts, or near its centre line when it's thin.
|
||||
let st = stroked(doc, ve.id)?;
|
||||
let w = doc.world_transform(ve.id);
|
||||
let (sub, t, _) = nearest(&st.lines, w.inverse() * p)?;
|
||||
let (q, _) = st.lines.get(sub)?.at(t)?;
|
||||
let half = 0.5 * st.weight * st.profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)) * w.determinant().abs().sqrt();
|
||||
((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t })
|
||||
}
|
||||
|
||||
/// Write node `id`'s width profile (through `node.set`'s key, so instances record the override).
|
||||
fn put_profile(doc: &mut Document, id: NodeId, profile: Option<&WidthProfile>) -> Result<()> {
|
||||
let v = profile.filter(|p| !p.points.is_empty()).map_or(Value::Null, |p| json!(p.points.iter().map(|q| [q.t, q.scale]).collect::<Vec<_>>()));
|
||||
crate::cmd::props::apply("vector.edit.widthPoint", doc, id, &serde_json::Map::from_iter([("widthProfile".to_string(), v)]))
|
||||
}
|
||||
|
||||
/// The node's profile to edit: its own, or uniform with a point at each end.
|
||||
fn profile_of(doc: &Document, id: NodeId) -> WidthProfile {
|
||||
doc.node(id).and_then(|n| n.stroke.profile()).cloned().unwrap_or_else(WidthProfile::uniform)
|
||||
}
|
||||
|
||||
/// The width scale for a handle dragged to local point `lp` from point `t` (the nearest
|
||||
/// subpath's point there counts).
|
||||
fn scale_at_pointer(st: &Stroked, t: f64, lp: Point) -> Option<f64> {
|
||||
let d = st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min);
|
||||
(d.is_finite() && st.weight > 0.0).then(|| 2.0 * d / st.weight)
|
||||
}
|
||||
|
||||
fn width_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "vector.edit.widthPoint";
|
||||
let ve = editing(s)?;
|
||||
let doc = s.doc()?;
|
||||
let weight = doc.get(ve.id)?.stroke.weight;
|
||||
let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| w / weight));
|
||||
let action = string(p, "action").unwrap_or_else(|| if get(p, "index").is_some() { "set".into() } else { "add".into() });
|
||||
let mut prof = profile_of(doc, ve.id);
|
||||
if action != "add" && doc.get(ve.id)?.stroke.profile().is_none() {
|
||||
return Err(bad(cmd, "the stroke has no width points (add one first)"));
|
||||
}
|
||||
let selected = match action.as_str() {
|
||||
"add" => {
|
||||
let t = req_num(cmd, p, "t")?;
|
||||
let sc = scale.unwrap_or_else(|| prof.scale_at(t));
|
||||
Some(prof.insert(t, sc))
|
||||
}
|
||||
"set" | "move" => {
|
||||
let i = req_index(cmd, p, "index")?;
|
||||
if !prof.set_point(i, num(p, "t"), scale) {
|
||||
return Err(bad(cmd, "no such width point"));
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
"remove" | "delete" => {
|
||||
let i = req_index(cmd, p, "index")?;
|
||||
if i >= prof.points.len() {
|
||||
return Err(bad(cmd, "no such width point"));
|
||||
}
|
||||
prof.points.remove(i);
|
||||
None
|
||||
}
|
||||
_ => return Err(bad(cmd, "action: add|set|remove")),
|
||||
};
|
||||
put_profile(s.doc_mut()?, ve.id, Some(&prof))?;
|
||||
set_state(s, |e| e.width_point = selected)?;
|
||||
Ok(json!({"index": selected, "points": prof.points.len()}))
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"vector.edit.widthPoint",
|
||||
"Width point",
|
||||
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`",
|
||||
true,
|
||||
width_point
|
||||
)]
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Hover with the Variable width tool: highlight what a press would grab.
|
||||
pub(crate) fn width_hover(&mut self, ve: &super::VectorEdit, ev: &PointerEvent) -> Result<()> {
|
||||
self.tool.overlay.width_hover = width_hit(self.doc()?, ve, ev.p(), ev.zoom);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Press with the Variable width tool.
|
||||
pub(crate) fn width_down(&mut self, ve: &super::VectorEdit, ev: &PointerEvent) -> Result<()> {
|
||||
let id = ve.id;
|
||||
let h = width_hit(self.doc()?, ve, ev.p(), ev.zoom);
|
||||
self.tool.overlay.width_hover = None;
|
||||
if self.doc()?.get(id)?.stroke.weight <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let g = match h {
|
||||
Some(WHit::Handle { index }) => WidthGesture::Size { index, added: None, moved: false },
|
||||
Some(WHit::Point { index }) => WidthGesture::Slide { index, moved: false },
|
||||
Some(WHit::Path { t }) => {
|
||||
self.begin_interaction("Variable width")?;
|
||||
let mut index = 0;
|
||||
self.preview(|doc, _| {
|
||||
let mut prof = profile_of(doc, id);
|
||||
index = prof.insert(t, prof.scale_at(t));
|
||||
put_profile(doc, id, Some(&prof))
|
||||
})?;
|
||||
WidthGesture::Size { index, added: Some(t), moved: false }
|
||||
}
|
||||
None => {
|
||||
return set_state(self, |e| e.width_point = None);
|
||||
}
|
||||
};
|
||||
let (WidthGesture::Slide { index, .. } | WidthGesture::Size { index, .. }) = g;
|
||||
set_state(self, |e| e.width_point = Some(index))?;
|
||||
self.begin_interaction("Variable width")?;
|
||||
self.tool.gesture = Gesture::Vector(super::VGesture::Width(g));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drag with the Variable width tool (previewed against the gesture's snapshot).
|
||||
pub(crate) fn width_drag(&mut self, ve: &super::VectorEdit, g: WidthGesture, ev: &PointerEvent) -> Result<()> {
|
||||
let id = ve.id;
|
||||
let lp = self.doc()?.world_transform(id).inverse() * ev.p();
|
||||
let g = match g {
|
||||
WidthGesture::Slide { index, .. } => {
|
||||
self.preview(|doc, _| {
|
||||
let Some(st) = stroked(doc, id) else { return Ok(()) };
|
||||
let Some((_, t, _)) = nearest(&st.lines, lp) else { return Ok(()) };
|
||||
let mut prof = profile_of(doc, id);
|
||||
prof.set_point(index, Some(t), None);
|
||||
put_profile(doc, id, Some(&prof))
|
||||
})?;
|
||||
WidthGesture::Slide { index, moved: true }
|
||||
}
|
||||
WidthGesture::Size { index, added, .. } => {
|
||||
self.preview(|doc, _| {
|
||||
let Some(st) = stroked(doc, id) else { return Ok(()) };
|
||||
let mut prof = profile_of(doc, id);
|
||||
if let Some(t) = added {
|
||||
prof.insert(t, prof.scale_at(t));
|
||||
}
|
||||
let Some(t) = prof.points.get(index).map(|q| q.t) else { return Ok(()) };
|
||||
prof.set_point(index, None, scale_at_pointer(&st, t, lp));
|
||||
put_profile(doc, id, Some(&prof))
|
||||
})?;
|
||||
WidthGesture::Size { index, added, moved: true }
|
||||
}
|
||||
};
|
||||
self.tool.gesture = Gesture::Vector(super::VGesture::Width(g));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release: a drag (or a click that added a point) is one undo step.
|
||||
pub(crate) fn width_up(&mut self, g: WidthGesture) -> Result<()> {
|
||||
match g {
|
||||
WidthGesture::Slide { moved: true, .. } | WidthGesture::Size { moved: true, .. } | WidthGesture::Size { added: Some(_), .. } => {
|
||||
self.commit_interaction()
|
||||
}
|
||||
_ => self.cancel_interaction(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Variable width strokes: the width profile command, the Variable width tool in vector edit mode
|
||||
//! (gestures, keys, undo) and what outline stroke and export make of a profile.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::at(kind, x, y, 1.0)
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, to.0, to.1)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap();
|
||||
}
|
||||
|
||||
/// An open path from (0, 50) to (100, 50) with a 10 wide centred stroke.
|
||||
fn path(s: &mut Session) -> NodeId {
|
||||
let r = ex(s, "vector.create", json!({"points": [[0, 50], [100, 50]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(s, "node.set", json!({"id": id, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
|
||||
id
|
||||
}
|
||||
|
||||
fn profile(s: &Session, id: NodeId) -> Option<WidthProfile> {
|
||||
s.doc().unwrap().node(id).unwrap().stroke.width_profile.clone()
|
||||
}
|
||||
|
||||
fn points(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
|
||||
profile(s, id).map(|p| p.points.iter().map(|q| ((q.t * 1e6).round() / 1e6, (q.scale * 1e6).round() / 1e6)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ve(s: &Session) -> VectorEdit {
|
||||
s.ds().unwrap().vector_edit.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_profile_command_sets_presets_and_points() {
|
||||
let mut s = Session::new();
|
||||
let id = path(&mut s);
|
||||
ex(&mut s, "stroke.widthProfile", json!({"ids": [id], "preset": "eye"}));
|
||||
assert_eq!(profile(&s, id), WidthPreset::Eye.profile());
|
||||
// Inspect reports it (preset and points), and node.set takes it back.
|
||||
let n = ex(&mut s, "node.get", json!({"id": id}));
|
||||
assert_eq!(n["widthProfile"]["preset"], json!("eye"));
|
||||
let back = n["widthProfile"].clone();
|
||||
ex(&mut s, "stroke.widthProfile", json!({"ids": [id], "points": [[1, 2], [0, 0.5]]}));
|
||||
assert_eq!(points(&s, id), vec![(0.0, 0.5), (1.0, 2.0)], "sorted");
|
||||
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": back}}));
|
||||
assert_eq!(profile(&s, id), WidthPreset::Eye.profile());
|
||||
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": [{"t": 0.5, "scale": 3}]}}));
|
||||
assert_eq!(points(&s, id), vec![(0.5, 3.0)]);
|
||||
// Uniform (or nothing) clears it; one undo step each.
|
||||
ex(&mut s, "stroke.widthProfile", json!({"ids": [id], "preset": "uniform"}));
|
||||
assert_eq!(profile(&s, id), None);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(points(&s, id), vec![(0.5, 3.0)]);
|
||||
ex(&mut s, "stroke.widthProfile", json!({"ids": [id]}));
|
||||
assert_eq!(profile(&s, id), None);
|
||||
assert!(s.execute("stroke.widthProfile", &json!({"ids": [id], "preset": "zigzag"})).is_err());
|
||||
assert!(s.execute("stroke.widthProfile", &json!({"ids": [id], "points": [[0, "x"]]})).is_err());
|
||||
assert!(s.execute("node.set", &json!({"id": id, "props": {"widthProfile": 7}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_tool_drags_add_slide_and_size_points_in_one_step_each() {
|
||||
let mut s = Session::new();
|
||||
let id = path(&mut s);
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
assert!(key(&mut s, "W", true, false, false).unwrap());
|
||||
assert_eq!(ve(&s).tool, VTool::Width);
|
||||
// Hovering the stroke offers to add a point there.
|
||||
s.pointer(&ev(PointerKind::Hover, 50.0, 52.0)).unwrap();
|
||||
assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t }) if (t - 0.5).abs() < 1e-9));
|
||||
// Dragging from the path adds a point and sets its width: 20 from the centre = 40 wide.
|
||||
drag(&mut s, (50.0, 50.0), (50.0, 70.0));
|
||||
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 4.0), (1.0, 1.0)]);
|
||||
assert_eq!(ve(&s).width_point, Some(1));
|
||||
let b = s.doc().unwrap().world_render_bounds(id).unwrap();
|
||||
assert!(b.y0 <= 30.0 + 1e-6 && b.y1 >= 70.0 - 1e-6, "paint bounds follow the profile: {b:?}");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(profile(&s, id), None, "adding and sizing is one step");
|
||||
s.redo().unwrap();
|
||||
// Its handles sit 20 either side; dragging one sets the width.
|
||||
let hs = width_handles(s.doc().unwrap(), &ve(&s), 1.0);
|
||||
let h = hs.iter().find(|h| h.index == 1).unwrap();
|
||||
assert!((h.sides[0][1] - 70.0).abs() < 1e-6 || (h.sides[1][1] - 70.0).abs() < 1e-6, "{h:?}");
|
||||
s.pointer(&ev(PointerKind::Hover, 50.0, 30.0)).unwrap();
|
||||
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1 }));
|
||||
drag(&mut s, (50.0, 30.0), (50.0, 45.0));
|
||||
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 1.0), (1.0, 1.0)]);
|
||||
// Dragging the point slides it along the path.
|
||||
drag(&mut s, (50.0, 50.0), (80.0, 58.0));
|
||||
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.8, 1.0), (1.0, 1.0)]);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(points(&s, id)[1], (0.5, 1.0));
|
||||
// Delete removes the selected width point (not the path's points).
|
||||
s.redo().unwrap();
|
||||
assert!(key(&mut s, "Delete", false, false, false).unwrap());
|
||||
assert_eq!(points(&s, id), vec![(0.0, 1.0), (1.0, 1.0)]);
|
||||
assert_eq!(network(s.doc().unwrap(), id).unwrap().vertices.len(), 2);
|
||||
// A click away from the stroke deselects.
|
||||
s.pointer(&ev(PointerKind::Down, 50.0, 0.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 50.0, 0.0)).unwrap();
|
||||
assert_eq!(ve(&s).width_point, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_point_command_adds_moves_and_removes() {
|
||||
let mut s = Session::new();
|
||||
let id = path(&mut s);
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
let r = ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.25, "width": 30}));
|
||||
assert_eq!(r["index"], json!(1));
|
||||
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.25, 3.0), (1.0, 1.0)]);
|
||||
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "t": 0.75, "scale": 0.5}));
|
||||
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.75, 0.5), (1.0, 1.0)]);
|
||||
// Without a width the point keeps the width already there.
|
||||
let before = profile(&s, id).unwrap();
|
||||
ex(&mut s, "vector.edit.widthPoint", json!({"action": "add", "t": 0.5}));
|
||||
let p = profile(&s, id).unwrap();
|
||||
assert!((p.points[1].scale - before.scale_at(0.5)).abs() < 1e-9, "{p:?}");
|
||||
let st = ex(&mut s, "vector.edit.state", json!({}));
|
||||
assert_eq!(st["widthPoints"].as_array().unwrap().len(), 4);
|
||||
for i in [3, 2, 1, 0] {
|
||||
ex(&mut s, "vector.edit.widthPoint", json!({"action": "remove", "index": i}));
|
||||
}
|
||||
assert_eq!(profile(&s, id), None, "removing every point makes the stroke uniform");
|
||||
assert!(s.execute("vector.edit.widthPoint", &json!({"index": 5, "t": 0.5})).is_err());
|
||||
assert!(s.execute("vector.edit.widthPoint", &json!({"action": "remove", "index": 0})).is_err());
|
||||
assert!(s.execute("vector.edit.widthPoint", &json!({"action": "spin"})).is_err());
|
||||
assert!(s.execute("vector.edit.widthPoint", &json!({"action": "add"})).is_err(), "add needs t");
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
assert!(matches!(s.execute("vector.edit.widthPoint", &json!({"t": 0.5})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_and_exports_follow_the_profile() {
|
||||
let mut s = Session::new();
|
||||
let id = path(&mut s);
|
||||
ex(&mut s, "stroke.widthProfile", json!({"ids": [id], "points": [[0, 0], [0.5, 4], [1, 0]]}));
|
||||
// SVG writes the varying outline as a filled path (non-zero), PDF and PNG render.
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let svg = String::from_utf8(crate::cmd::file::export_bytes(&doc, &[id], ExportFormat::Svg, 1.0).unwrap()).unwrap();
|
||||
assert!(svg.contains("<path") && !svg.contains("evenodd"), "{svg}");
|
||||
let pdf = crate::cmd::file::export_bytes(&doc, &[id], ExportFormat::Pdf, 1.0).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
let png = crate::cmd::file::export_bytes(&doc, &[id], ExportFormat::Png, 1.0).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
// Outline stroke makes a vector shaped like the varying stroke: 40 tall in the middle.
|
||||
let r = ex(&mut s, "vector.outlineStroke", json!({"ids": [id]}));
|
||||
let v = crate::params::id_of(&r["ids"][0]).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(v).unwrap();
|
||||
assert!((b.height() - 40.0).abs() < 0.5 && (b.width() - 100.0).abs() < 0.5, "{b:?}");
|
||||
let n = s.doc().unwrap().node(v).unwrap();
|
||||
assert!(n.stroke.profile().is_none() || n.strokes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_commands_survive_junk() {
|
||||
let junk = [
|
||||
json!({}),
|
||||
json!(null),
|
||||
json!({"index": -1, "t": "NaN", "width": 1e308, "scale": -5}),
|
||||
json!({"action": "remove", "index": 1e12}),
|
||||
json!({"action": "add", "t": 1e300, "scale": 1e300}),
|
||||
json!({"preset": 7, "points": [[0, 0], [1], "x"]}),
|
||||
json!({"points": {"points": {"preset": "eye"}}}),
|
||||
];
|
||||
for cmd in ["stroke.widthProfile", "vector.edit.widthPoint"] {
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let id = path(&mut s);
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
if let Some(p) = profile(&s, id) {
|
||||
assert!(p.points.iter().all(|q| q.t.is_finite() && (0.0..=1.0).contains(&q.t) && q.scale.is_finite()), "{cmd} {j}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@
|
||||
//! - [`boolean`]: Union, Subtract, Intersect and Exclude over any number of closed paths (robust
|
||||
//! sweep-line topology from `linesweeper`); results use the even-odd rule.
|
||||
//! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment,
|
||||
//! dashes, caps, joins and arrowheads.
|
||||
//! dashes, caps, joins, arrowheads and variable width profiles ([`width`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod width;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps};
|
||||
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
|
||||
@@ -271,30 +275,34 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
|
||||
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
|
||||
.with_start_cap(kcap(props.cap_start))
|
||||
.with_end_cap(kcap(props.cap_end));
|
||||
let dashes: Vec<f64> = props.dashes.iter().copied().filter(|d| d.is_finite() && *d >= 0.0).collect();
|
||||
if !dashes.is_empty() && dashes.iter().sum::<f64>() > 0.01 {
|
||||
let pat = if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes };
|
||||
if let Some(pat) = width::dash_pattern(props) {
|
||||
style = style.with_dashes(0.0, pat);
|
||||
}
|
||||
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
|
||||
}
|
||||
|
||||
/// The painted stroke area of a shape as a fill path (local coordinates). `closed` says whether the
|
||||
/// shape is closed (Inside/Outside only apply to closed shapes).
|
||||
/// shape is closed (Inside/Outside only apply to closed shapes). A width profile makes the width
|
||||
/// vary along the path (fill the result non-zero when centred).
|
||||
pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) -> BezPath {
|
||||
let w = props.weight;
|
||||
if !(w.is_finite() && w > 0.0) {
|
||||
return BezPath::new();
|
||||
}
|
||||
// Centred at `width` (twice the weight for Inside/Outside, which clip half of it away).
|
||||
let centred = |width: f64| match props.profile() {
|
||||
Some(p) => width::variable_outline(path, width, props, p).unwrap_or_else(|| stroke_center(path, width, props)),
|
||||
None => stroke_center(path, width, props),
|
||||
};
|
||||
let align = if closed_shape { props.align } else { StrokeAlign::Center };
|
||||
let mut out = match align {
|
||||
StrokeAlign::Center => stroke_center(path, w, props),
|
||||
StrokeAlign::Center => centred(w),
|
||||
StrokeAlign::Inside => {
|
||||
let s = stroke_center(path, w * 2.0, props);
|
||||
let s = centred(w * 2.0);
|
||||
binary(&s, path, linesweeper::BinaryOp::Intersection).unwrap_or(s)
|
||||
}
|
||||
StrokeAlign::Outside => {
|
||||
let s = stroke_center(path, w * 2.0, props);
|
||||
let s = centred(w * 2.0);
|
||||
binary(&s, path, linesweeper::BinaryOp::Difference).unwrap_or(s)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Variable width strokes: a path measured by length, and the outline of a stroke whose width
|
||||
//! follows a [`WidthProfile`] along each subpath.
|
||||
//!
|
||||
//! The path is flattened into polylines (sampled densely enough for the width to vary smoothly),
|
||||
//! each point is offset along the polyline's normals by the interpolated half-width, the sides are
|
||||
//! joined (round, miter or bevel on the outer side, through the centre on the inner side) and
|
||||
//! capped. The result may overlap itself; fill it with the non-zero rule.
|
||||
|
||||
use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile};
|
||||
use figcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
|
||||
|
||||
/// Flattening tolerance (local units).
|
||||
const FLATTEN_TOL: f64 = 0.05;
|
||||
/// Every subpath gets at least this many width samples, so the width varies smoothly along long
|
||||
/// straight runs.
|
||||
const SAMPLES_PER_SUBPATH: f64 = 128.0;
|
||||
/// The most points the outline works with; longer paths fall back to a uniform stroke.
|
||||
pub const MAX_WIDTH_SAMPLES: usize = 40_000;
|
||||
/// The most dashes a dashed variable width stroke makes.
|
||||
const MAX_DASHES: usize = 20_000;
|
||||
|
||||
/// One subpath flattened, with the length along it at each point. A closed subpath ends on its
|
||||
/// start point.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Polyline {
|
||||
pub points: Vec<Point>,
|
||||
/// Cumulative length at each point (starts at 0).
|
||||
pub lengths: Vec<f64>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Polyline {
|
||||
pub fn length(&self) -> f64 {
|
||||
self.lengths.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn push(&mut self, p: Point) {
|
||||
let Some(last) = self.points.last().copied() else {
|
||||
self.points.push(p);
|
||||
self.lengths.push(0.0);
|
||||
return;
|
||||
};
|
||||
let d = (p - last).hypot();
|
||||
if d > 1e-9 {
|
||||
self.points.push(p);
|
||||
self.lengths.push(self.length() + d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at `t` (0..1 by length) and the unit normal there (the direction turned 90°).
|
||||
pub fn at(&self, t: f64) -> Option<(Point, Vec2)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 || !t.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let s = t.clamp(0.0, 1.0) * len;
|
||||
let i = self.lengths.partition_point(|l| *l <= s).clamp(1, self.points.len().saturating_sub(1).max(1));
|
||||
let (a, b) = (*self.points.get(i - 1)?, *self.points.get(i)?);
|
||||
let (la, lb) = (*self.lengths.get(i - 1)?, *self.lengths.get(i)?);
|
||||
let u = if lb - la > 1e-12 { ((s - la) / (lb - la)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = b - a;
|
||||
let n = d.turn_90() / d.hypot().max(1e-12);
|
||||
Some((a.lerp(b, u), n))
|
||||
}
|
||||
|
||||
/// The nearest point to `p`: (t by length, distance).
|
||||
pub fn nearest(&self, p: Point) -> Option<(f64, f64)> {
|
||||
let len = self.length();
|
||||
if len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (i, w) in self.points.windows(2).enumerate() {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let l2 = d.hypot2();
|
||||
let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let dist = (a.lerp(b, u) - p).hypot();
|
||||
if best.is_none_or(|(_, bd)| dist < bd) {
|
||||
let s = self.lengths.get(i).copied().unwrap_or(0.0) + u * l2.sqrt();
|
||||
best = Some((s / len, dist));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subpath of `path` flattened. `None` when the path has invalid coordinates or is too
|
||||
/// long to work with.
|
||||
pub fn polylines(path: &BezPath) -> Option<Vec<Polyline>> {
|
||||
if !crate::finite(path) {
|
||||
return None;
|
||||
}
|
||||
fn finish(cur: &mut Polyline, out: &mut Vec<Polyline>) {
|
||||
let done = std::mem::take(cur);
|
||||
if done.points.len() >= 2 {
|
||||
out.push(done);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Polyline> = Vec::new();
|
||||
let mut cur = Polyline::default();
|
||||
let mut start = Point::ZERO;
|
||||
let mut count = 0usize;
|
||||
kurbo::flatten(path.iter(), FLATTEN_TOL, |el| {
|
||||
count += 1;
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
finish(&mut cur, &mut out);
|
||||
start = p;
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
if cur.points.is_empty() {
|
||||
cur.push(start);
|
||||
}
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
cur.push(start);
|
||||
cur.closed = cur.points.len() >= 3;
|
||||
finish(&mut cur, &mut out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
finish(&mut cur, &mut out);
|
||||
(count <= MAX_WIDTH_SAMPLES).then_some(out)
|
||||
}
|
||||
|
||||
/// The nearest point of the stroke path to `p`: (subpath index, t by length, distance).
|
||||
pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> {
|
||||
lines.iter().enumerate().filter_map(|(i, l)| l.nearest(p).map(|(t, d)| (i, t, d))).min_by(|a, b| a.2.total_cmp(&b.2))
|
||||
}
|
||||
|
||||
/// One straight piece of an offset polyline: its ends, the half-widths there, its direction and
|
||||
/// normal.
|
||||
struct Seg {
|
||||
a: Point,
|
||||
b: Point,
|
||||
ha: f64,
|
||||
hb: f64,
|
||||
d: Vec2,
|
||||
n: Vec2,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Style {
|
||||
join: StrokeJoin,
|
||||
miter_limit: f64,
|
||||
cap_start: StrokeCap,
|
||||
cap_end: StrokeCap,
|
||||
}
|
||||
|
||||
/// The cap shape an end cap draws (arrowheads are added by the caller; their stroke ends round
|
||||
/// or square like the uniform stroke does).
|
||||
fn cap_shape(c: StrokeCap) -> StrokeCap {
|
||||
match c {
|
||||
StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round,
|
||||
StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square,
|
||||
_ => StrokeCap::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Points of an arc around `c` from the direction of `from` turning by `sweep` radians, with the
|
||||
/// radius going from `r0` to `r1` (ends excluded).
|
||||
fn arc(out: &mut Vec<Point>, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64) {
|
||||
let r = r0.max(r1);
|
||||
if r <= FLATTEN_TOL || !sweep.is_finite() {
|
||||
return;
|
||||
}
|
||||
let step = 2.0 * (1.0 - FLATTEN_TOL / r).clamp(-1.0, 1.0).acos();
|
||||
let n = ((sweep.abs() / step.max(1e-3)).ceil() as usize).clamp(2, 64);
|
||||
let a0 = from.atan2();
|
||||
for k in 1..n {
|
||||
let f = k as f64 / n as f64;
|
||||
out.push(c + Vec2::from_angle(a0 + sweep * f) * (r0 + (r1 - r0) * f));
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner between segments `p` and `c` on side `sigma` (+1 = the normal's side).
|
||||
fn join(out: &mut Vec<Point>, p: &Seg, c: &Seg, sigma: f64, style: Style) {
|
||||
let pe = p.b + p.n * (sigma * p.hb);
|
||||
let cs = c.a + c.n * (sigma * c.ha);
|
||||
out.push(pe);
|
||||
let (cross, dot) = (p.d.cross(c.d), p.d.dot(c.d));
|
||||
if cross.abs() < 1e-12 && dot > 0.0 {
|
||||
out.push(cs);
|
||||
return;
|
||||
}
|
||||
// Turning towards the normal puts that side inside the corner.
|
||||
let outer = if cross.abs() < 1e-12 { sigma < 0.0 } else { cross * sigma < 0.0 };
|
||||
let v = c.a;
|
||||
if outer {
|
||||
let theta = if cross.abs() < 1e-12 { std::f64::consts::PI } else { cross.atan2(dot) };
|
||||
match style.join {
|
||||
StrokeJoin::Round => arc(out, v, pe - v, theta, p.hb, c.ha),
|
||||
StrokeJoin::Miter => {
|
||||
let half = (theta / 2.0).cos();
|
||||
let bis = p.n + c.n;
|
||||
if half > 1e-6 && 1.0 / half <= style.miter_limit && bis.hypot() > 1e-9 {
|
||||
out.push(v + bis * (sigma * (p.hb + c.ha) / 2.0 / half / bis.hypot()));
|
||||
}
|
||||
}
|
||||
StrokeJoin::Bevel => {}
|
||||
}
|
||||
} else {
|
||||
// Through the centre: keeps the winding right when segments are shorter than the width.
|
||||
out.push(v);
|
||||
}
|
||||
out.push(cs);
|
||||
}
|
||||
|
||||
/// One side of the offset polyline (`sigma` = +1 along the normals, -1 against).
|
||||
fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec<Point> {
|
||||
let mut out = Vec::with_capacity(segs.len() * 3 + 2);
|
||||
for (j, s) in segs.iter().enumerate() {
|
||||
let prev = if j == 0 { closed.then(|| segs.last()).flatten() } else { segs.get(j - 1) };
|
||||
match prev {
|
||||
Some(p) => join(&mut out, p, s, sigma, style),
|
||||
None => out.push(s.a + s.n * (sigma * s.ha)),
|
||||
}
|
||||
}
|
||||
if !closed && let Some(l) = segs.last() {
|
||||
out.push(l.b + l.n * (sigma * l.hb));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cap at `c` facing `dir` (unit, outwards) with half-width `h`: from the `dir.turn_90()` side
|
||||
/// to the other one.
|
||||
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, h: f64, kind: StrokeCap) {
|
||||
let n = dir.turn_90();
|
||||
match cap_shape(kind) {
|
||||
StrokeCap::Square => {
|
||||
out.push(c + n * h + dir * h);
|
||||
out.push(c - n * h + dir * h);
|
||||
}
|
||||
StrokeCap::Round => arc(out, c, n, -std::f64::consts::PI, h, h),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_contour(path: &mut BezPath, pts: impl IntoIterator<Item = Point>) {
|
||||
let mut it = pts.into_iter();
|
||||
let Some(first) = it.next() else { return };
|
||||
path.move_to(first);
|
||||
for p in it {
|
||||
path.line_to(p);
|
||||
}
|
||||
path.close_path();
|
||||
}
|
||||
|
||||
/// Outline one piece (a subpath or a dash) with its half-widths.
|
||||
fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, style: Style) {
|
||||
let segs: Vec<Seg> = pts
|
||||
.windows(2)
|
||||
.zip(hw.windows(2))
|
||||
.filter_map(|(p, h)| {
|
||||
let (a, b) = (p[0], p[1]);
|
||||
let len = (b - a).hypot();
|
||||
(len > 1e-9).then(|| {
|
||||
let d = (b - a) / len;
|
||||
Seg { a, b, ha: h[0], hb: h[1], d, n: d.turn_90() }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (Some(first), Some(last)) = (segs.first(), segs.last()) else {
|
||||
// A zero-length piece is a dot with round caps.
|
||||
if let (Some(p), Some(h)) = (pts.first(), hw.first())
|
||||
&& cap_shape(style.cap_start) == StrokeCap::Round
|
||||
&& *h > 0.0
|
||||
{
|
||||
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, *h), FLATTEN_TOL));
|
||||
}
|
||||
return;
|
||||
};
|
||||
let left = side(&segs, 1.0, closed, style);
|
||||
let right = side(&segs, -1.0, closed, style);
|
||||
if closed {
|
||||
push_contour(path, left);
|
||||
push_contour(path, right.into_iter().rev());
|
||||
return;
|
||||
}
|
||||
let mut ring = left;
|
||||
cap(&mut ring, last.b, last.d, last.hb, style.cap_end);
|
||||
ring.extend(right.into_iter().rev());
|
||||
cap(&mut ring, first.a, -first.d, first.ha, style.cap_start);
|
||||
push_contour(path, ring);
|
||||
}
|
||||
|
||||
/// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths).
|
||||
fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec<Point>, Vec<f64>)> {
|
||||
let mut out = Vec::new();
|
||||
let Some(&first) = pattern.first() else { return out };
|
||||
let (mut idx, mut left, mut on) = (0usize, first, true);
|
||||
let mut cur: (Vec<Point>, Vec<f64>) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect());
|
||||
for (p, h) in pts.windows(2).zip(hw.windows(2)) {
|
||||
let (a, b, ha, hb) = (p[0], p[1], h[0], h[1]);
|
||||
let len = (b - a).hypot();
|
||||
let mut used = 0.0;
|
||||
while len - used > left {
|
||||
used += left;
|
||||
let f = if len > 0.0 { used / len } else { 0.0 };
|
||||
let (q, hq) = (a.lerp(b, f), ha + (hb - ha) * f);
|
||||
if on {
|
||||
cur.0.push(q);
|
||||
cur.1.push(hq);
|
||||
out.push(std::mem::take(&mut cur));
|
||||
if out.len() >= MAX_DASHES {
|
||||
return out;
|
||||
}
|
||||
} else {
|
||||
cur = (vec![q], vec![hq]);
|
||||
}
|
||||
on = !on;
|
||||
idx = (idx + 1) % pattern.len();
|
||||
left = pattern.get(idx).copied().unwrap_or(first);
|
||||
}
|
||||
left -= len - used;
|
||||
if on {
|
||||
cur.0.push(b);
|
||||
cur.1.push(hb);
|
||||
}
|
||||
}
|
||||
if on && !cur.0.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The dash pattern a stroke uses (odd lengths repeat twice), or `None` for a solid stroke.
|
||||
pub fn dash_pattern(props: &StrokeProps) -> Option<Vec<f64>> {
|
||||
let dashes: Vec<f64> = props.dashes.iter().copied().filter(|d| d.is_finite() && *d >= 0.0).collect();
|
||||
if dashes.is_empty() || dashes.iter().sum::<f64>() <= 0.01 {
|
||||
return None;
|
||||
}
|
||||
Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes })
|
||||
}
|
||||
|
||||
/// Resample a polyline so no step is longer than `max_step`; returns the points and the length at
|
||||
/// each, or `None` past the sample budget.
|
||||
fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec<Point>, Vec<f64>)> {
|
||||
let mut pts: Vec<Point> = Vec::with_capacity(line.points.len());
|
||||
let mut lens: Vec<f64> = Vec::with_capacity(line.points.len());
|
||||
for (i, p) in line.points.iter().enumerate() {
|
||||
let s = line.lengths.get(i).copied().unwrap_or(0.0);
|
||||
if let (Some(&prev), Some(&ps)) = (pts.last(), lens.last()) {
|
||||
let n = ((s - ps) / max_step).ceil().clamp(1.0, MAX_WIDTH_SAMPLES as f64) as usize;
|
||||
for k in 1..n {
|
||||
let f = k as f64 / n as f64;
|
||||
pts.push(Point::lerp(prev, *p, f));
|
||||
lens.push(ps + (s - ps) * f);
|
||||
}
|
||||
}
|
||||
pts.push(*p);
|
||||
lens.push(s);
|
||||
if pts.len() > *budget {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
*budget -= pts.len();
|
||||
Some((pts, lens))
|
||||
}
|
||||
|
||||
/// The filled outline of a stroke of `width` along `path` whose width follows `profile` (its
|
||||
/// scales multiply `width`). `None` when the path is invalid or too long (the caller strokes it
|
||||
/// uniformly instead).
|
||||
pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile: &WidthProfile) -> Option<BezPath> {
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return Some(BezPath::new());
|
||||
}
|
||||
let profile = profile.sanitized();
|
||||
let lines = polylines(path)?;
|
||||
let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start: props.cap_start, cap_end: props.cap_end };
|
||||
let pattern = dash_pattern(props);
|
||||
let mut budget = MAX_WIDTH_SAMPLES;
|
||||
let mut out = BezPath::new();
|
||||
for line in &lines {
|
||||
let len = line.length();
|
||||
if len <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let (pts, lens) = resample(line, len / SAMPLES_PER_SUBPATH, &mut budget)?;
|
||||
let hw: Vec<f64> = lens.iter().map(|s| 0.5 * width * profile.scale_at(s / len)).collect();
|
||||
match &pattern {
|
||||
Some(pat) if len / pat.iter().sum::<f64>() * (pat.len() as f64) < MAX_DASHES as f64 => {
|
||||
for (p, h) in dashes(&pts, &hw, pat) {
|
||||
outline_piece(&mut out, &p, &h, false, style);
|
||||
}
|
||||
}
|
||||
_ => outline_piece(&mut out, &pts, &hw, line.closed, style),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Variable width strokes: measuring paths and outlining strokes whose width follows a profile.
|
||||
|
||||
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, WidthPreset, WidthProfile};
|
||||
use figcraft_geom::kurbo::{BezPath, Circle, Point, Rect, Shape};
|
||||
|
||||
use crate::stroke_outline;
|
||||
use crate::width::*;
|
||||
|
||||
fn line(x1: f64) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((x1, 0.0));
|
||||
p
|
||||
}
|
||||
|
||||
fn props(weight: f64, profile: Option<WidthProfile>) -> StrokeProps {
|
||||
StrokeProps { weight, align: StrokeAlign::Center, width_profile: profile, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Whether the non-zero fill of `p` covers `pt`.
|
||||
fn covers(p: &BezPath, x: f64, y: f64) -> bool {
|
||||
p.winding(Point::new(x, y)) != 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_paths_by_length() {
|
||||
let mut p = line(100.0);
|
||||
p.move_to((0.0, 50.0));
|
||||
p.line_to((0.0, 150.0));
|
||||
let lines = polylines(&p).unwrap();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].length(), 100.0);
|
||||
let (pt, n) = lines[0].at(0.25).unwrap();
|
||||
assert_eq!(pt, Point::new(25.0, 0.0));
|
||||
assert!((n.x).abs() < 1e-12 && (n.y - 1.0).abs() < 1e-12, "{n:?}");
|
||||
assert_eq!(lines[0].at(1.0).unwrap().0, Point::new(100.0, 0.0));
|
||||
let (i, t, d) = nearest(&lines, Point::new(30.0, 5.0)).unwrap();
|
||||
assert_eq!((i, d), (0, 5.0));
|
||||
assert!((t - 0.3).abs() < 1e-12, "{t}");
|
||||
let (i, t, _) = nearest(&lines, Point::new(-3.0, 100.0)).unwrap();
|
||||
assert_eq!((i, t), (1, 0.5));
|
||||
// A closed circle ends where it starts.
|
||||
let c = Circle::new((0.0, 0.0), 10.0).to_path(0.01);
|
||||
let l = &polylines(&c).unwrap()[0];
|
||||
assert!(l.closed);
|
||||
assert_eq!(l.points.first(), l.points.last());
|
||||
assert!((l.length() - std::f64::consts::TAU * 10.0).abs() < 0.1, "{}", l.length());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_follows_the_profile() {
|
||||
// A wedge from 10 wide to a point: a triangle of area 500.
|
||||
let s = stroke_outline(&line(100.0), &props(10.0, WidthPreset::Wedge.profile()), false);
|
||||
assert!((s.area().abs() - 500.0).abs() < 2.0, "{}", s.area());
|
||||
assert!(covers(&s, 5.0, 4.0) && !covers(&s, 95.0, 4.0), "wide at the start, thin at the end");
|
||||
// A uniform profile matches the plain stroke, caps included.
|
||||
for cap in [StrokeCap::None, StrokeCap::Round, StrokeCap::Square] {
|
||||
let uniform = StrokeProps { cap_start: cap, cap_end: cap, ..props(10.0, Some(WidthProfile::uniform())) };
|
||||
let plain = StrokeProps { width_profile: None, ..uniform.clone() };
|
||||
let (a, b) = (stroke_outline(&line(100.0), &uniform, false), stroke_outline(&line(100.0), &plain, false));
|
||||
assert!((a.area().abs() - b.area().abs()).abs() < 1.0, "{cap:?}: {} vs {}", a.area(), b.area());
|
||||
let (ba, bb) = (a.bounding_box(), b.bounding_box());
|
||||
assert!((ba.x0 - bb.x0).abs() < 0.1 && (ba.x1 - bb.x1).abs() < 0.1 && (ba.y1 - bb.y1).abs() < 0.1, "{cap:?}: {ba:?} vs {bb:?}");
|
||||
}
|
||||
// Scales above 1 widen the stroke past its weight.
|
||||
let wide = stroke_outline(&line(100.0), &props(10.0, Some(WidthProfile::new([(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)]))), false);
|
||||
assert!((wide.bounding_box().y1 - 15.0).abs() < 0.01, "{:?}", wide.bounding_box());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_paths_make_a_ring_per_subpath() {
|
||||
let c = Circle::new((50.0, 50.0), 40.0).to_path(0.01);
|
||||
let s = stroke_outline(&c, &props(10.0, WidthPreset::Eye.profile()), true);
|
||||
assert!(!covers(&s, 50.0, 50.0), "the middle stays open");
|
||||
// The eye is widest halfway round (the circle starts at its right-hand point).
|
||||
assert!(covers(&s, 14.0, 50.0) && covers(&s, 6.0, 50.0));
|
||||
assert!(!covers(&s, 94.0, 50.0) && !covers(&s, 86.0, 50.0), "pointed at the start");
|
||||
// Inside alignment stays inside the shape.
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
|
||||
let inside = stroke_outline(&r, &StrokeProps { align: StrokeAlign::Inside, ..props(10.0, WidthPreset::Taper.profile()) }, true);
|
||||
let b = inside.bounding_box();
|
||||
assert!(b.x0 >= -0.01 && b.y0 >= -0.01 && b.x1 <= 100.01 && b.y1 <= 100.01, "{b:?}");
|
||||
assert!(inside.area().abs() > 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_and_dashes() {
|
||||
let mut corner = BezPath::new();
|
||||
corner.move_to((0.0, 0.0));
|
||||
corner.line_to((100.0, 0.0));
|
||||
corner.line_to((100.0, 100.0));
|
||||
let at = |join| {
|
||||
let p = StrokeProps { join, ..props(20.0, Some(WidthProfile::uniform())) };
|
||||
stroke_outline(&corner, &p, false)
|
||||
};
|
||||
let (miter, bevel, round) = (at(StrokeJoin::Miter), at(StrokeJoin::Bevel), at(StrokeJoin::Round));
|
||||
// The outer corner is (110, -10): the miter reaches it, the others cut it.
|
||||
assert!(covers(&miter, 109.0, -9.0));
|
||||
assert!(!covers(&bevel, 109.0, -9.0) && !covers(&round, 109.0, -9.0));
|
||||
assert!(covers(&round, 106.0, -6.0) && !covers(&bevel, 106.0, -6.0), "round bulges past the bevel");
|
||||
// The inner corner is filled.
|
||||
assert!(covers(&bevel, 95.0, 5.0) && covers(&bevel, 91.0, 9.0));
|
||||
// Dashes: half of a 100 long, 2 wide line.
|
||||
let dashed = StrokeProps { dashes: vec![10.0, 10.0], ..props(2.0, Some(WidthProfile::uniform())) };
|
||||
let s = stroke_outline(&line(100.0), &dashed, false);
|
||||
assert!((s.area().abs() - 100.0).abs() < 1.0, "{}", s.area());
|
||||
assert!(covers(&s, 5.0, 0.0) && !covers(&s, 15.0, 0.0));
|
||||
// Dashes keep the width of where they are on the path.
|
||||
let tapered = StrokeProps { dashes: vec![10.0, 10.0], ..props(10.0, WidthPreset::Wedge.profile()) };
|
||||
let s = stroke_outline(&line(100.0), &tapered, false);
|
||||
assert!(covers(&s, 5.0, 4.0) && !covers(&s, 85.0, 4.0));
|
||||
// Zero-length dashes with round caps are dots.
|
||||
let dots =
|
||||
StrokeProps { dashes: vec![0.0, 10.0], cap_start: StrokeCap::Round, cap_end: StrokeCap::Round, ..props(4.0, Some(WidthProfile::uniform())) };
|
||||
let s = stroke_outline(&line(100.0), &dots, false);
|
||||
assert!(covers(&s, 10.0, 1.0) && !covers(&s, 5.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_and_huge_input_is_bounded() {
|
||||
let mut bad = BezPath::new();
|
||||
bad.move_to((f64::NAN, 0.0));
|
||||
bad.line_to((1.0, 1.0));
|
||||
assert!(polylines(&bad).is_none());
|
||||
let _ = stroke_outline(&bad, &props(4.0, WidthPreset::Eye.profile()), false);
|
||||
// A path too long to sample falls back to the uniform stroke.
|
||||
let mut long = BezPath::new();
|
||||
long.move_to((0.0, 0.0));
|
||||
for i in 0..(MAX_WIDTH_SAMPLES + 10) {
|
||||
long.line_to((i as f64, (i % 2) as f64 * 5.0));
|
||||
}
|
||||
assert!(variable_outline(&long, 2.0, &props(2.0, None), &WidthProfile::uniform()).is_none());
|
||||
assert!(!stroke_outline(&long, &props(2.0, WidthPreset::Eye.profile()), false).elements().is_empty());
|
||||
// Zero width, empty paths, a single point and a dash pattern of zeros.
|
||||
assert!(variable_outline(&line(10.0), 0.0, &props(0.0, None), &WidthProfile::uniform()).unwrap().elements().is_empty());
|
||||
assert!(variable_outline(&BezPath::new(), 2.0, &props(2.0, None), &WidthProfile::uniform()).unwrap().elements().is_empty());
|
||||
let mut dot = BezPath::new();
|
||||
dot.move_to((5.0, 5.0));
|
||||
dot.line_to((5.0, 5.0));
|
||||
let _ = stroke_outline(&dot, &props(2.0, WidthPreset::Eye.profile()), false);
|
||||
let zeros = StrokeProps { dashes: vec![0.0, 0.0], ..props(2.0, Some(WidthProfile::uniform())) };
|
||||
assert!(!stroke_outline(&line(10.0), &zeros, false).elements().is_empty(), "solid when the pattern is empty");
|
||||
// A tiny dash pattern on a long path doesn't explode.
|
||||
let tiny = StrokeProps { dashes: vec![0.01, 0.01], ..props(2.0, Some(WidthProfile::uniform())) };
|
||||
let s = stroke_outline(&line(1e6), &tiny, false);
|
||||
assert!(s.elements().len() < 1_000_000);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
|
||||
}
|
||||
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
|
||||
if props.dashes.is_empty() && closed && props.align != StrokeAlign::Center {
|
||||
if props.dashes.is_empty() && props.profile().is_none() && closed && props.align != StrokeAlign::Center {
|
||||
let w = props.weight;
|
||||
let ring = |inset: f64| -> Option<BezPath> {
|
||||
let (x0, y0, ww, hh) = (inset, inset, n.width - 2.0 * inset, n.height - 2.0 * inset);
|
||||
@@ -135,6 +135,10 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
for d in &props.dashes {
|
||||
d.to_bits().hash(&mut h);
|
||||
}
|
||||
props.dashes.len().hash(&mut h);
|
||||
for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() {
|
||||
(p.t.to_bits(), p.scale.to_bits()).hash(&mut h);
|
||||
}
|
||||
let key = h.finish();
|
||||
if let Some(p) = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
|
||||
@@ -317,3 +317,5 @@ mod band_tests;
|
||||
mod retained_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Variable width strokes render along the path, past the weight where the profile widens them.
|
||||
|
||||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{NodeKind, WidthPreset, WidthProfile};
|
||||
|
||||
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
|
||||
let i = ((y * p.width() + x) * 4) as usize;
|
||||
p.data().get(i).is_some_and(|r| *r < 40)
|
||||
}
|
||||
|
||||
/// A horizontal line from (10, 50) to (90, 50) with a 20 wide black stroke.
|
||||
fn line_doc(profile: Option<WidthProfile>) -> (figcraft_doc::Document, figcraft_doc::NodeId) {
|
||||
let mut d = figcraft_doc::Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let l = d.create(p, NodeKind::Line, "L", 10.0, 50.0, 80.0, 0.0).unwrap();
|
||||
let n = d.get_mut(l).unwrap();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 20.0;
|
||||
n.stroke.width_profile = profile;
|
||||
(d, p)
|
||||
}
|
||||
|
||||
fn render(d: &figcraft_doc::Document, p: figcraft_doc::NodeId) -> Pixmap {
|
||||
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 100, height: 100 }, &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wedge_profile_tapers_the_stroke() {
|
||||
let (d, p) = line_doc(WidthPreset::Wedge.profile());
|
||||
let pix = render(&d, p);
|
||||
assert!(dark(&pix, 15, 58) && dark(&pix, 15, 42), "wide at the start");
|
||||
assert!(dark(&pix, 70, 50), "a quarter of the width three quarters along");
|
||||
assert!(!dark(&pix, 85, 54) && !dark(&pix, 85, 46), "thin at the end");
|
||||
// Uniform for comparison: full width at both ends.
|
||||
let (d, p) = line_doc(None);
|
||||
let pix = render(&d, p);
|
||||
assert!(dark(&pix, 85, 58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widened_profile_paints_past_the_weight() {
|
||||
// Three times the weight in the middle: 30 either side of the line.
|
||||
let (d, p) = line_doc(Some(WidthProfile::new([(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)])));
|
||||
let pix = render(&d, p);
|
||||
assert!(dark(&pix, 50, 77) && dark(&pix, 50, 23), "not culled at the weight's reach");
|
||||
assert!(!dark(&pix, 15, 70));
|
||||
}
|
||||
@@ -639,7 +639,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
if let Some(ve) = ds.vector_edit.clone()
|
||||
&& let Some(o) = figcraft_engine::vectoredit::overlay(&doc, &ve)
|
||||
{
|
||||
vector_overlay(app, painter, &o);
|
||||
// The Variable width tool shows width points in place of the path's points.
|
||||
let width_tool = ve.tool == figcraft_engine::vectoredit::VTool::Width;
|
||||
vector_overlay(app, painter, &o, !width_tool);
|
||||
if width_tool {
|
||||
width_overlay(app, painter, &doc, &ve, zoom);
|
||||
}
|
||||
}
|
||||
// Selection.
|
||||
let editing = ds.text_edit.clone();
|
||||
@@ -805,9 +810,42 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector edit mode: the network's segments, handles and points, the hovered part, the closed
|
||||
/// area under the paint bucket and the pen's rubber band.
|
||||
fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::vectoredit::EditOverlay) {
|
||||
/// Variable width tool: each width point on the path with a bar across the stroke to its two
|
||||
/// width handles (the selected point filled), and a faint preview where a drag would add one.
|
||||
fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &figcraft_engine::vectoredit::VectorEdit, zoom: f64) {
|
||||
use figcraft_engine::vectoredit::{WHit, width_handles, width_preview};
|
||||
let hover = app.session.tool.overlay.width_hover;
|
||||
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
|
||||
let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| {
|
||||
let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1]));
|
||||
let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT };
|
||||
painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0));
|
||||
painter.line_segment([a, b], stroke(col, 1.0));
|
||||
let grab = !ghost && hover == Some(WHit::Handle { index: h.index });
|
||||
for e in [a, b] {
|
||||
let r = if grab { 4.5 } else { 3.5 };
|
||||
painter.circle_filled(e, r, Color32::WHITE);
|
||||
painter.circle_stroke(e, r, stroke(col, 1.0));
|
||||
}
|
||||
let on = !ghost && ve.width_point == Some(h.index);
|
||||
let hovered = !ghost && hover == Some(WHit::Point { index: h.index });
|
||||
let r = if hovered { 5.0 } else { 4.0 };
|
||||
painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE });
|
||||
painter.circle_stroke(c, r, stroke(col, 1.0));
|
||||
};
|
||||
for h in width_handles(doc, ve, zoom) {
|
||||
draw(&h, false);
|
||||
}
|
||||
if let Some(WHit::Path { t }) = hover {
|
||||
for h in width_preview(doc, ve, t, zoom) {
|
||||
draw(&h, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector edit mode: the network's segments, handles and points (`points`), the hovered part, the
|
||||
/// closed area under the paint bucket and the pen's rubber band.
|
||||
fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::vectoredit::EditOverlay, points: bool) {
|
||||
use figcraft_engine::vectoredit::VHit;
|
||||
let ov = &app.session.tool.overlay;
|
||||
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
|
||||
@@ -831,6 +869,9 @@ fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::ve
|
||||
};
|
||||
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w)));
|
||||
}
|
||||
if !points {
|
||||
return;
|
||||
}
|
||||
for h in &o.handles {
|
||||
let (v, e) = (sp(&h.vertex), sp(&h.handle));
|
||||
painter.line_segment([v, e], stroke(ACCENT, 1.0));
|
||||
|
||||
@@ -139,6 +139,8 @@ icons! {
|
||||
Variable,
|
||||
Clock,
|
||||
Eraser,
|
||||
/// Variable width: a stroke swelling in the middle with a width handle above and below.
|
||||
VariableWidth,
|
||||
/// Shape builder: merge regions (and the tool).
|
||||
ShapeBuilder,
|
||||
/// Shape builder: remove regions.
|
||||
@@ -876,6 +878,19 @@ fn draw(p: &P, icon: Icon) {
|
||||
p.line(5.5, 7.0, 9.5, 11.0);
|
||||
p.line(7.0, 13.5, 14.0, 13.5);
|
||||
}
|
||||
VariableWidth => {
|
||||
// A lens-shaped stroke, thin at both ends, and a width handle on either side of its
|
||||
// widest point.
|
||||
let edge = |i: i32, side: f32| {
|
||||
let f = i as f32 / 12.0;
|
||||
(2.0 + 11.0 * f, 7.5 + side * 3.0 * (PI * f).sin())
|
||||
};
|
||||
let lens: Vec<(f32, f32)> = (0..=12).map(|i| edge(i, -1.0)).chain((0..=12).rev().map(|i| edge(i, 1.0))).collect();
|
||||
p.fill(&lens);
|
||||
p.line(7.5, 2.0, 7.5, 13.0);
|
||||
p.dot(7.5, 2.0, 1.5);
|
||||
p.dot(7.5, 13.0, 1.5);
|
||||
}
|
||||
Variable => {
|
||||
// A hexagon with a dot: an original mark for "bind a value".
|
||||
let pts: Vec<(f32, f32)> = (0..6)
|
||||
|
||||
@@ -173,6 +173,11 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||||
menu_item_with(ui, label, shortcut, |_, _, _| {})
|
||||
}
|
||||
|
||||
/// A menu row with art drawn into it after the label (`art(painter, row rect, text colour)`).
|
||||
pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width().max(180.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
|
||||
@@ -190,6 +195,7 @@ pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||||
if resp.hovered() { Color32::WHITE } else { t.text_secondary },
|
||||
);
|
||||
}
|
||||
art(ui.painter(), r, col);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
@@ -883,7 +889,78 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced stroke settings: dash pattern, join and the end points of open paths.
|
||||
/// A width profile drawn as a filled band across `rect` (thickest point = full height); `None`
|
||||
/// draws a uniform stroke.
|
||||
pub(crate) fn profile_art(painter: &egui::Painter, rect: Rect, profile: Option<&WidthProfile>, col: Color32) {
|
||||
const STEPS: u32 = 40;
|
||||
let max = profile.map_or(1.0, WidthProfile::max_scale).max(1e-6);
|
||||
let (cy, half) = (rect.center().y, rect.height() / 2.0);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let (mut top, mut bottom) = (Vec::new(), Vec::new());
|
||||
for i in 0..=STEPS {
|
||||
let t = f64::from(i) / f64::from(STEPS);
|
||||
let s = profile.map_or(0.5, |p| p.scale_at(t) / max) as f32;
|
||||
let x = rect.min.x + rect.width() * t as f32;
|
||||
let h = (s * half).max(0.5);
|
||||
top.push(pos2(x, cy - h));
|
||||
bottom.push(pos2(x, cy + h));
|
||||
mesh.colored_vertex(pos2(x, cy - h), col);
|
||||
mesh.colored_vertex(pos2(x, cy + h), col);
|
||||
}
|
||||
for i in 0..STEPS {
|
||||
let k = 2 * i;
|
||||
mesh.add_triangle(k, k + 1, k + 2);
|
||||
mesh.add_triangle(k + 1, k + 3, k + 2);
|
||||
}
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
// The mesh isn't anti-aliased: smooth its edges with hairlines.
|
||||
for edge in [top, bottom] {
|
||||
painter.add(egui::Shape::line(edge, Stroke::new(0.75, col)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke settings ▸ Width profile: the presets (each with its shape) and a flip.
|
||||
fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Option<Option<WidthProfile>>, half: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::sublabels(ui, "Width profile", None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let label = match &profile {
|
||||
None => "Mixed",
|
||||
Some(None) => WidthPreset::Uniform.label(),
|
||||
Some(Some(p)) => p.preset().map_or("Custom", WidthPreset::label),
|
||||
};
|
||||
let resp = widgets::dropdown(ui, label, half * 2.0 + 8.0 - 32.0, None);
|
||||
if let Some(p) = &profile {
|
||||
let r = resp.rect;
|
||||
let art = Rect::from_min_max(pos2(r.max.x - 22.0 - 56.0, r.center().y - 5.0), pos2(r.max.x - 22.0, r.center().y + 5.0));
|
||||
profile_art(ui.painter(), art, p.as_ref(), t.icon_secondary);
|
||||
}
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for preset in WidthPreset::ALL {
|
||||
let prof = preset.profile();
|
||||
let art = |pt: &egui::Painter, r: Rect, col: Color32| {
|
||||
let a = Rect::from_min_max(pos2(r.max.x - 8.0 - 64.0, r.center().y - 5.0), pos2(r.max.x - 8.0, r.center().y + 5.0));
|
||||
profile_art(pt, a, prof.as_ref(), col);
|
||||
};
|
||||
if menu_item_with(ui, preset.label(), "", art) {
|
||||
app.run("stroke.widthProfile", json!({"ids": ids, "preset": preset.id()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Flip: the profile runs from the other end of the path.
|
||||
let custom = profile.clone().flatten();
|
||||
let flip = icons::button(ui, Icon::FlipH, 24.0, false, "Flip width profile");
|
||||
if flip.clicked()
|
||||
&& let Some(p) = custom
|
||||
{
|
||||
let pts: Vec<[f64; 2]> = p.points.iter().rev().map(|q| [1.0 - q.t, q.scale]).collect();
|
||||
app.run("stroke.widthProfile", json!({"ids": ids, "points": pts}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths.
|
||||
fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||||
@@ -915,6 +992,7 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
app.field(&ids, "dashes", e, |v| json!([dash, v.max(0.0)]));
|
||||
});
|
||||
}
|
||||
width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()), half);
|
||||
let join = st.as_ref().map(|s| s.join);
|
||||
widgets::sublabels(ui, "Join", (join == Some(StrokeJoin::Miter)).then_some("Miter angle"));
|
||||
row(ui, 32.0, |ui| {
|
||||
|
||||
@@ -185,6 +185,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
||||
VTool::Lasso => Icon::Lasso,
|
||||
VTool::Cut => Icon::Cut,
|
||||
VTool::Eraser => Icon::Eraser,
|
||||
VTool::Width => Icon::VariableWidth,
|
||||
};
|
||||
if tool_cell(ui, &t, r, ("vtool", tool.id()), icon, active).on_hover_text(tip).clicked() {
|
||||
app.run("vector.edit.tool", json!({"tool": tool.id()}));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Design panel in vector edit mode: the selected points' position, corner radius and handle
|
||||
//! mirroring (in place of the layer's position section).
|
||||
//! mirroring, or the selected width point with the Variable width tool (in place of the layer's
|
||||
//! position section).
|
||||
|
||||
use egui::{Id, Ui};
|
||||
use figcraft_doc::{Document, HandleMirroring};
|
||||
@@ -28,7 +29,38 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: imp
|
||||
}
|
||||
}
|
||||
|
||||
/// The Variable width tool's selected width point: where it is along the path and its width.
|
||||
fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
||||
widgets::section_header(ui, "Width point", &[], false);
|
||||
let stroke = doc.node(ve.id).map(|n| &n.stroke);
|
||||
let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?)));
|
||||
let Some((i, p)) = sel else {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
widgets::label(ui, "Drag the stroke to add a width point", t.text_secondary);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
return;
|
||||
};
|
||||
let weight = stroke.map_or(1.0, |s| s.weight);
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0;
|
||||
widgets::sublabels(ui, "Position", Some("Width"));
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("wpt_t"), Lead::None, Some(round(p.t * 100.0)), "%", col, 1.0);
|
||||
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "t": v / 100.0}));
|
||||
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon), Some(round(p.scale * weight)), "", col, 1.0);
|
||||
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "width": v.max(0.0)}));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
||||
if ve.tool == vectoredit::VTool::Width {
|
||||
width_point(app, ui, doc, ve);
|
||||
return;
|
||||
}
|
||||
let Some(net) = vectoredit::network(doc, ve.id) else { return };
|
||||
let pts = vectoredit::selected_points(net, ve);
|
||||
widgets::section_header(ui, if pts.len() > 1 { "Points" } else { "Point" }, &[], false);
|
||||
|
||||
@@ -84,7 +84,7 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
|
||||
|
||||
### `ui.key {key, shift?, alt?, cmd?, ctrl?}`
|
||||
|
||||
A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
|
||||
A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` `Shift+E` `Shift+W` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
|
||||
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with `cmd` they resize the selection). While text is being
|
||||
edited, keys go to the text editor. Returns `{"command": id}` for shortcuts.
|
||||
|
||||
|
||||
+9
-1
@@ -133,7 +133,7 @@ Or in a project's `.mcp.json`:
|
||||
|
||||
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
|
||||
`vector.edit.begin {id}` (converts shapes to vectors), `vector.edit.state` (page-space overlay, the
|
||||
local network and each point's handle mirroring), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser}`,
|
||||
local network, each point's handle mirroring and the width points), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser|width}`,
|
||||
`vector.edit.select {vertices?, segments?, add?}`, `vector.edit.selectAll`, `vector.edit.moveVertices`,
|
||||
`vector.edit.nudge`, `vector.edit.position {x?, y?}`, `vector.edit.setHandle`, `vector.edit.mirroring
|
||||
{mode: none|angle|angleAndLength}`, `vector.edit.smooth {smooth?}`, `vector.edit.bend`,
|
||||
@@ -146,6 +146,14 @@ from the pen's open end; clicking a point connects or branches), `vector.edit.pe
|
||||
`vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in
|
||||
`vector.edit.state`; local coordinates are the layer's own.
|
||||
|
||||
Variable width strokes: `stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch,
|
||||
points?: [[t, scale], ...]}` (also `node.set {widthProfile}`; `t` runs 0–1 along each subpath by
|
||||
length, `scale` multiplies the stroke weight, widths in between are interpolated smoothly; no
|
||||
preset or points = uniform). While editing, the Variable width tool (`width`, ⇧W) drags width
|
||||
points: `vector.edit.widthPoint {t, width? (px) | scale?}` adds one, `{index, t?, width? | scale?}`
|
||||
moves or resizes it, `{action: "remove", index}` deletes it. Rendering, PNG/SVG/PDF export and
|
||||
`vector.outlineStroke` follow the profile.
|
||||
|
||||
### Prototypes
|
||||
|
||||
Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep
|
||||
|
||||
Reference in New Issue
Block a user