mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 15:52:52 +00:00
M3.64: width profiles run smooth between their points
A width profile's sides now run as a cubic Hermite curve through the points, its slope at each one the weighted harmonic mean of the secants either side (Fritsch-Carlson). That is C1 continuous and monotone between points, so it never overshoots them, a width change eases in and out instead of making a corner, and moving one point only reshapes the two segments beside it. The ends are flat; two points at one t are still a step. The outline samples the curve along the path (densify subdivides each profile segment) so the smooth width draws on straight runs too, and a flat profile keeps its old straight pieces. Hit testing now counts every segment near enough to reach the point, as the wider side of a farther one can paint past the nearest; gradient strips reach over projecting dash caps. Tests updated for the new sampled widths; new tests cover smoothing, monotonicity, locality and steps.
This commit is contained in:
+111
-11
@@ -223,25 +223,37 @@ pub struct ProfilePreset {
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// (left, right) width factors at `t`, linear between points.
|
||||
/// (left, right) width factors at `t`: a smooth cubic between the points.
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
///
|
||||
/// The width runs as a cubic Hermite curve through the points, its slope at each one the
|
||||
/// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1
|
||||
/// continuous and monotone between points, so it never overshoots them, a point eases into
|
||||
/// its neighbours instead of making a corner, and moving one only reshapes the two segments
|
||||
/// beside it. The ends are flat, where the width is constant outside the profile. Two points
|
||||
/// at one `t` are still a step: the runs either side are independent and flat there.
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
let (Some(first), Some(last)) = (p.first(), p.last()) else { return (1.0, 1.0) };
|
||||
if t <= first.0 {
|
||||
return (first.1, first.2);
|
||||
}
|
||||
if t <= p[0].0 {
|
||||
return (p[0].1, p[0].2);
|
||||
if t >= last.0 {
|
||||
return (last.1, last.2);
|
||||
}
|
||||
for w in p.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
|
||||
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
|
||||
}
|
||||
// The segment ending at or after `t`: a step's zero-length segment is skipped, so `t`
|
||||
// exactly at a step still reads the width just before it (as `around` documents).
|
||||
let Some(i) = p.windows(2).position(|w| t <= w[1].0) else { return (last.1, last.2) };
|
||||
let (Some(&(t0, l0, r0)), Some(&(t1, l1, r1))) = (p.get(i), p.get(i + 1)) else { return (last.1, last.2) };
|
||||
let h = t1 - t0;
|
||||
if h <= 1e-12 {
|
||||
return (l1, r1);
|
||||
}
|
||||
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
|
||||
let u = (t - t0) / h;
|
||||
let (ml0, mr0, ml1, mr1) = (slope(p, i, false), slope(p, i, true), slope(p, i + 1, false), slope(p, i + 1, true));
|
||||
(hermite(u, h, l0, l1, ml0, ml1), hermite(u, h, r0, r1, mr0, mr1))
|
||||
}
|
||||
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
|
||||
pub const PRESETS: [ProfilePreset; 7] = [
|
||||
@@ -293,6 +305,35 @@ impl WidthProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slope of the smooth width curve at `p[i]` for one side: the weighted harmonic mean of the
|
||||
/// secants either side, or flat at a local extremum and at the ends of a run (the profile's ends
|
||||
/// and either side of a step).
|
||||
fn slope(p: &[(f64, f64, f64)], i: usize, right: bool) -> f64 {
|
||||
let value = |k: usize| p.get(k).map_or(0.0, |q| if right { q.2 } else { q.1 });
|
||||
let (Some(&(t, _, _)), Some(&(tm, _, _)), Some(&(tp, _, _))) = (p.get(i), i.checked_sub(1).and_then(|k| p.get(k)), p.get(i + 1)) else {
|
||||
return 0.0;
|
||||
};
|
||||
let (h0, h1) = (t - tm, tp - t);
|
||||
if h0 <= 1e-12 || h1 <= 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let (d0, d1) = ((value(i) - value(i - 1)) / h0, (value(i + 1) - value(i)) / h1);
|
||||
if d0 * d1 <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (2.0 * h1 + h0, h1 + 2.0 * h0);
|
||||
let m = (a + b) / (a / d0 + b / d1);
|
||||
// A file's widths are only checked to be finite: keep a slope that overflows flat.
|
||||
if m.is_finite() { m } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A cubic Hermite from `y0` at `u = 0` to `y1` at `u = 1` over `h` (in `t`), with `m0`/`m1` the
|
||||
/// slopes at its ends (width factor per `t`).
|
||||
fn hermite(u: f64, h: f64, y0: f64, y1: f64, m0: f64, m1: f64) -> f64 {
|
||||
let (u2, u3) = (u * u, u * u * u);
|
||||
(2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * h * m0 + (3.0 * u2 - 2.0 * u3) * y1 + (u3 - u2) * h * m1
|
||||
}
|
||||
|
||||
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
|
||||
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
|
||||
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
|
||||
@@ -1302,6 +1343,65 @@ mod tests {
|
||||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// The width runs smoothly between points: it passes through them, has one slope either side
|
||||
/// of one, and never overshoots the widths its points set.
|
||||
#[test]
|
||||
fn profile_is_smooth_between_points() {
|
||||
let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)];
|
||||
assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12);
|
||||
let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6;
|
||||
// A local minimum at the middle point is flat there, so the two sides agree.
|
||||
assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5));
|
||||
for k in 0..=100 {
|
||||
let w = WidthProfile::at_points(&p, k as f64 / 100.0).0;
|
||||
assert!((0.2..=1.0).contains(&w), "{w} at {k}");
|
||||
}
|
||||
// The side with no points of its own stays flat.
|
||||
for k in 0..=100 {
|
||||
assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-point profile is a smoothstep: it eases out of and into the constant ends, and its
|
||||
/// midpoint is still the linear one.
|
||||
#[test]
|
||||
fn profile_eases_into_its_ends() {
|
||||
let p = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)];
|
||||
assert!((WidthProfile::at_points(&p, 0.5).0 - 0.5).abs() < 1e-12);
|
||||
let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6;
|
||||
assert!(slope(0.0).abs() < 1e-3, "{}", slope(0.0));
|
||||
assert!(slope(1.0).abs() < 1e-3, "{}", slope(1.0));
|
||||
let mut prev = 0.0;
|
||||
for k in 0..=100 {
|
||||
let w = WidthProfile::at_points(&p, k as f64 / 100.0).0;
|
||||
assert!((0.0..=1.0).contains(&w) && w >= prev - 1e-12, "{w} at {k}");
|
||||
prev = w;
|
||||
}
|
||||
}
|
||||
|
||||
/// A step still steps, and the smooth runs either side meet it flat.
|
||||
#[test]
|
||||
fn profile_steps_stay_steps() {
|
||||
let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)];
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0));
|
||||
}
|
||||
|
||||
/// Moving a point only reshapes the segments beside it (local influence), not the whole path.
|
||||
#[test]
|
||||
fn profile_points_only_affect_their_neighbouring_segments() {
|
||||
let a = [(0.0, 0.0, 0.0), (0.25, 1.0, 1.0), (0.5, 0.5, 0.5), (0.75, 1.0, 1.0), (1.0, 0.0, 0.0)];
|
||||
let mut b = a;
|
||||
b[3] = (0.75, 0.8, 0.8);
|
||||
for k in 0..=50 {
|
||||
let t = k as f64 / 100.0;
|
||||
assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_presets_round_trip_their_ids() {
|
||||
for p in WidthProfile::PRESETS {
|
||||
|
||||
@@ -168,7 +168,8 @@ impl StrokeLayer {
|
||||
|
||||
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
|
||||
/// outside strokes of closed paths count on their own side only (and within `tol` of the
|
||||
/// path); a width profile counts at its width where `p` is nearest.
|
||||
/// path); a width profile counts at the width of every segment that can reach `p`, as the
|
||||
/// wider side of a farther one can paint past the nearest.
|
||||
pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
|
||||
let reach = self.reach() + tol;
|
||||
if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) {
|
||||
@@ -192,11 +193,13 @@ impl StrokeLayer {
|
||||
let segs = drawn(&els[r]);
|
||||
let (half, region) = self.body(closed);
|
||||
let full = half * self.profile_max() * dash_caps.unwrap_or(1.0);
|
||||
// The nearest point on this subpath.
|
||||
let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq));
|
||||
if let Some((i, nr)) = near
|
||||
&& nr.distance_sq.sqrt() <= full + tol
|
||||
{
|
||||
// Every segment near enough to reach `p`: with a width profile the wider side of a
|
||||
// farther segment can paint past the nearest one, so the nearest alone is not enough.
|
||||
let near = segs.iter().enumerate().filter_map(|(i, s)| {
|
||||
let nr = s.nearest(p, 1e-9);
|
||||
(nr.distance_sq.sqrt() <= full + tol).then_some((i, nr))
|
||||
});
|
||||
for (i, nr) in near {
|
||||
let d = nr.distance_sq.sqrt();
|
||||
let factor = if dash_caps.is_some() {
|
||||
full / half.max(1e-12)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use kurbo::{BezPath, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color};
|
||||
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
||||
use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::width::{flatten, left, length, split_at};
|
||||
@@ -147,7 +147,10 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
if !(hw > 0.0 && hw.is_finite()) {
|
||||
return vec![];
|
||||
}
|
||||
let full = hw * st.profile_max();
|
||||
// A projecting cap on every dash reaches a corner half a width along the path and half across
|
||||
// from the dash's end, so the ribs reach further to cover what those dashes paint.
|
||||
let capped = st.cap == LineCap::Square && st.dash.as_ref().is_some_and(|d| d.is_dashed());
|
||||
let full = hw * st.profile_max() * if capped { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
let heads = (st.head_reach(false), st.head_reach(true));
|
||||
let cap = cap_extent(st.cap, 2.0 * full);
|
||||
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
|
||||
|
||||
@@ -155,9 +155,10 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
|
||||
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
|
||||
});
|
||||
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
|
||||
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip.
|
||||
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip,
|
||||
// where the smooth profile is thinner than a straight ramp (a smoothstep's first tenth).
|
||||
assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide");
|
||||
assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin");
|
||||
assert!(!o.contains(Point::new(5.0, 0.5)) && o.contains(Point::new(5.0, 0.1)), "first dash is thin");
|
||||
assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty");
|
||||
// Dots too: a round dot near the middle is bigger than one near an end.
|
||||
let st = stroke(10.0, |s| {
|
||||
@@ -166,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
|
||||
s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false });
|
||||
});
|
||||
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
|
||||
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5)));
|
||||
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.4)));
|
||||
}
|
||||
|
||||
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
|
||||
|
||||
@@ -84,17 +84,35 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>,
|
||||
(pts, corner)
|
||||
}
|
||||
|
||||
/// Insert samples where the profile has width points (so the piecewise-linear profile is exact
|
||||
/// along straight runs; the two points of a discontinuous point share one sample). `span` maps the
|
||||
/// subpath onto the profile (see [`outline_spans`]).
|
||||
/// Insert samples where the profile has width points and between them, so its smooth curve is
|
||||
/// drawn as a smooth outline along straight runs too (the two points of a discontinuous point
|
||||
/// share one sample). `span` maps the subpath onto the profile (see [`outline_spans`]).
|
||||
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec<Point>, Vec<bool>) {
|
||||
// Samples per profile segment; fewer when a profile carries many points, so a file can't turn
|
||||
// the extra samples into a huge allocation.
|
||||
const SAMPLES: usize = 16;
|
||||
let total = length(pts, closed);
|
||||
let range = span.1 - span.0;
|
||||
if total <= 1e-12 || range <= 1e-12 {
|
||||
return (pts.to_vec(), corner.to_vec());
|
||||
}
|
||||
// Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
|
||||
let marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect();
|
||||
let mut ts: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
|
||||
// A flat profile draws the same straight pieces as before: sample only its points.
|
||||
let flat = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12));
|
||||
let samples = if flat { 1 } else { SAMPLES.min((4096 / ts.len().max(1)).max(1)) };
|
||||
let mut marks = Vec::with_capacity(ts.len() * samples);
|
||||
for w in ts.windows(2) {
|
||||
for k in 0..samples {
|
||||
marks.push(w[0] + (w[1] - w[0]) * k as f64 / samples as f64);
|
||||
}
|
||||
}
|
||||
if let Some(&last) = ts.last() {
|
||||
marks.push(last);
|
||||
}
|
||||
let marks: Vec<f64> = marks.into_iter().map(|t| (t - span.0) / range * total).collect();
|
||||
split_at(pts, corner, closed, &marks)
|
||||
}
|
||||
|
||||
@@ -315,8 +333,10 @@ mod tests {
|
||||
assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The lens eases up to full width and back down (a smooth bump averaging half the width); a
|
||||
/// one-sided profile keeps its side at zero and stays on the other.
|
||||
#[test]
|
||||
fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() {
|
||||
fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() {
|
||||
let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01));
|
||||
assert!((a - 500.0).abs() < 1e-6, "{a}");
|
||||
let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Adjoining Width Points and the render of a step.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::WidthProfile;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
@@ -216,16 +217,27 @@ fn new_width_point_without_adjoining_preserves_neighbours() {
|
||||
|
||||
#[test]
|
||||
fn new_width_point_adjust_adjoining_uses_sampled_widths() {
|
||||
let base = [(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)];
|
||||
for indexed in [false, true] {
|
||||
let (mut s, id) = line_session();
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 9, 9], [0.25, 1, 0], [0.75, 3, 0], [1, 7, 7]]})).unwrap();
|
||||
// At t=0.5 the old sides are 10pt and 0pt. Indexed editing of that sampled
|
||||
// point is the existing convention: scale the nonzero side, add from zero.
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": base})).unwrap();
|
||||
// At t=0.5 the curve's sides are what a new point starts from (an indexed edit starts from
|
||||
// the point it made there: 10pt and 0pt, half the 10pt stroke's 5pt).
|
||||
let old = if indexed { (2.0, 0.0) } else { WidthProfile { points: base.to_vec() }.at(0.5) };
|
||||
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 0}))) } else { None };
|
||||
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true}));
|
||||
// Scale the nonzero side, add from zero (as Adjust Adjoining Width Points does).
|
||||
let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o };
|
||||
let (nl, nr) = (4.0, 1.0);
|
||||
assert_eq!(
|
||||
points(&s, id),
|
||||
vec![(0.0, 9.0, 9.0), (0.25, 2.0, 1.0), (0.5, 4.0, 1.0), (0.75, 6.0, 1.0), (1.0, 7.0, 7.0)],
|
||||
vec![
|
||||
(0.0, 9.0, 9.0),
|
||||
(0.25, scale(1.0, old.0, nl), scale(0.0, old.1, nr)),
|
||||
(0.5, nl, nr),
|
||||
(0.75, scale(3.0, old.0, nl), scale(0.0, old.1, nr)),
|
||||
(1.0, 7.0, 7.0),
|
||||
],
|
||||
"only nearest neighbours change, using the sampled sides; indexed={indexed}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,8 +374,10 @@ mod tests {
|
||||
assert!(d.fields.get("index").is_none_or(Value::is_null));
|
||||
let actual_t = d.f64("t", -1.0);
|
||||
assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25");
|
||||
assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6);
|
||||
assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6);
|
||||
// The smooth profile's sides at a quarter: smoothstep(0.25) = 0.15625 of 1..2 and
|
||||
// 0.5..1.5, on a 10pt stroke (5pt half).
|
||||
assert!((d.f64("side1", 0.0) - 5.78125).abs() < 1e-6);
|
||||
assert!((d.f64("side2", 0.0) - 3.28125).abs() < 1e-6);
|
||||
assert!(!d.bool("linked"));
|
||||
d.fields.insert("side1".into(), json!(8));
|
||||
d.fields.insert("side2".into(), json!(3));
|
||||
|
||||
@@ -681,7 +681,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.
|
||||
- **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; a point only reshapes the segments beside it, and coincident points still step.
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`).
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
@@ -855,6 +855,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect
|
||||
| 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. |
|
||||
| 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. |
|
||||
| 2026-10-19 | minor | Libraries panel: the list fills the panel and scrolls (also while a drag nears its edges); items drag before or after others of their kind and into or out of groups, and groups drag by their headers (`library.moveItem` `before`/`after`, `library.moveGroup`). Scores and estimates unchanged. |
|
||||
| 2026-10-19 | minor | Width profiles run as a smooth C1 curve between their points (a monotone cubic Hermite, Fritsch-Carlson) instead of straight lines, sampled along the path so the outline follows it on straight runs; steps between coincident points still step. Regression tests cover easing, monotonicity, locality and steps. Scores and estimates unchanged. |
|
||||
| 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. |
|
||||
| 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. |
|
||||
| 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user