mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
Merge pull request #3145 from adambelis/feat/width-smooth-profile
Width profiles run smooth between their points
This commit is contained in:
@@ -26,11 +26,16 @@ impl WidthProfile {
|
||||
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). Each side runs through
|
||||
/// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point
|
||||
/// without a kink, turns flat at a point wider or narrower than both neighbours, and never
|
||||
/// goes past either point of the span it is in (#5714). Points on one straight line stay on
|
||||
/// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it.
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
///
|
||||
/// Each side runs through its points on a cubic Hermite curve, 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 reshapes the curve as far as its neighbours'
|
||||
/// neighbours (their slopes depend on it). The ends of a run use the one-sided PCHIP slope
|
||||
/// from its first two secants, or the secant when it has only two points, so a two-point
|
||||
/// profile stays a straight taper and the lens is a parabola. Two points at one `t` are still
|
||||
/// a step: each run either side ends independently at it.
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
@@ -71,9 +76,10 @@ impl WidthProfile {
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each
|
||||
/// side stays between the widths of the two points of each span ([`Self::at_points`]), but the
|
||||
/// two sides needn't peak together, so a span counts its wider left and its wider right.
|
||||
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points.
|
||||
/// Each side's smooth curve stays between the widths of the two points of each span
|
||||
/// ([`Self::at_points`]), but the two sides needn't peak together, so a span counts its wider
|
||||
/// left and its wider right.
|
||||
pub fn widest(&self) -> f64 {
|
||||
let p = &self.points;
|
||||
if p.is_empty() {
|
||||
@@ -249,11 +255,27 @@ impl AngleProfile {
|
||||
/// How close two width points' positions are to make a step ([`WidthProfile::around`]).
|
||||
const STEP: f64 = 1e-7;
|
||||
|
||||
/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants.
|
||||
/// A sign change caps the slope, and a slope that turns back on the first secant is flattened.
|
||||
/// Runs of only two points use their secant instead (see [`tangent`]).
|
||||
fn end_slope(h0: f64, h1: f64, d0: f64, d1: f64) -> f64 {
|
||||
let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
|
||||
if !m.is_finite() || m * d0 <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if d0 * d1 < 0.0 {
|
||||
return if d0 > 0.0 { m.min(3.0 * d0) } else { m.max(3.0 * d0) };
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in
|
||||
/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where
|
||||
/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes
|
||||
/// of the spans on either side elsewhere (never more than three times either, so no span
|
||||
/// overshoots its points), and the span's own slope at an end of the profile or beside a step.
|
||||
/// the point is wider or narrower than both neighbours, and the weighted harmonic mean of the
|
||||
/// slopes of the spans on either side elsewhere (never more than three times either, so no span
|
||||
/// overshoots its points). The ends of a run take the one-sided three-point PCHIP slope, or their
|
||||
/// secant when the run has only two points; a step ends the run on each side, so the points either
|
||||
/// side of it do not see across it.
|
||||
fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 {
|
||||
let Some(q) = p.get(j) else { return 0.0 };
|
||||
let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP);
|
||||
@@ -268,8 +290,18 @@ fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f
|
||||
let (h0, h1) = (q.0 - a.0, b.0 - q.0);
|
||||
3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1)
|
||||
}
|
||||
(Some(a), None) => slope(a, q),
|
||||
(None, Some(b)) => slope(q, b),
|
||||
// The end of a run: its previous two points set a one-sided three-point slope, or its
|
||||
// secant when the run has only two points (the point before `a` is on the other side of a
|
||||
// step, or does not exist).
|
||||
(Some(a), None) => {
|
||||
let Some(prev) = j.checked_sub(2).and_then(|k| p.get(k)).filter(|z| a.0 - z.0 > STEP) else { return slope(a, q) };
|
||||
end_slope(q.0 - a.0, a.0 - prev.0, slope(a, q), slope(prev, a))
|
||||
}
|
||||
// The start of a run, symmetrically.
|
||||
(None, Some(b)) => {
|
||||
let Some(next) = p.get(j + 2).filter(|z| z.0 - b.0 > STEP) else { return slope(q, b) };
|
||||
end_slope(b.0 - q.0, next.0 - b.0, slope(q, b), slope(b, next))
|
||||
}
|
||||
(None, None) => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -424,12 +456,12 @@ mod tests {
|
||||
#[test]
|
||||
fn widths_run_smoothly_through_width_points() {
|
||||
// A swell in the middle: flat at its widest, and a quarter of the way along above the
|
||||
// straight ramp from 1 to 3 (2).
|
||||
// straight ramp from 1 to 3 (2) because the end eases out of the first point.
|
||||
let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] };
|
||||
let (before, after) = slopes(&swell, 0.5);
|
||||
assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}");
|
||||
let (l, r) = swell.at(0.25);
|
||||
assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}");
|
||||
assert!((l - 2.5).abs() < 1e-12 && (r - 1.75).abs() < 1e-12, "{l} {r}");
|
||||
// Rising on both sides of a point: the slope is the same either side of it.
|
||||
let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] };
|
||||
let (before, after) = slopes(&rise, 0.3);
|
||||
|
||||
@@ -197,12 +197,85 @@ fn strokes_without_pressure_round_trip_unchanged() {
|
||||
fn profile_interp() {
|
||||
let p = WidthProfile::lens();
|
||||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||||
// Half way up the lens the width curves above the straight ramp (0.5), flattening into its
|
||||
// widest point (#5714).
|
||||
assert_eq!(p.at(0.25), (0.625, 0.625));
|
||||
// The lens is a parabola between its ends, so a quarter of the way along is 0.75: above the
|
||||
// 0.5 a straight taper would give.
|
||||
assert_eq!(p.at(0.25), (0.75, 0.75));
|
||||
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 run with only two points takes its secant as both end slopes, so it stays a straight
|
||||
/// taper on each side instead of becoming a smoothstep.
|
||||
#[test]
|
||||
fn profile_two_point_runs_are_linear() {
|
||||
let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)];
|
||||
for k in 0..=100 {
|
||||
let t = k as f64 / 100.0;
|
||||
let (l, r) = WidthProfile::at_points(&p, t);
|
||||
assert!((l - t).abs() < 1e-12, "left {l} at {t}");
|
||||
assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75,
|
||||
/// above the 0.5 a straight line between its points would give.
|
||||
#[test]
|
||||
fn profile_lens_is_convex() {
|
||||
let p = WidthProfile::lens();
|
||||
assert!((p.at(0.25).0 - 0.75).abs() < 1e-12);
|
||||
assert!((p.at(0.75).0 - 0.75).abs() < 1e-12);
|
||||
for k in 1..25 {
|
||||
let t = k as f64 / 100.0;
|
||||
assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0);
|
||||
assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_the_curve_around_them() {
|
||||
// A monotone run, so no neighbouring slope is held flat by a local extremum.
|
||||
let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)];
|
||||
let mut b = a;
|
||||
b[3] = (0.75, 0.9, 0.9);
|
||||
// Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the
|
||||
// curve: nothing before p[1] may move.
|
||||
for k in 0..=24 {
|
||||
let t = k as f64 / 100.0;
|
||||
assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}");
|
||||
}
|
||||
assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_presets_round_trip_their_ids() {
|
||||
for p in WidthProfile::PRESETS {
|
||||
|
||||
+19
-10
@@ -80,10 +80,8 @@ fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> {
|
||||
/// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the
|
||||
/// segment is a corner. The stroke draws the sides there straight to the corner's join and inner
|
||||
/// meeting point, which can lie outside the exact width all along the segment.
|
||||
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) {
|
||||
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) {
|
||||
let n = segs.len();
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
|
||||
let total: f64 = lens.iter().sum::<f64>().max(1e-12);
|
||||
let before = lens[..i].iter().sum::<f64>();
|
||||
let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total);
|
||||
let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]);
|
||||
@@ -306,7 +304,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) {
|
||||
@@ -330,17 +329,27 @@ 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))
|
||||
});
|
||||
// The lengths are only needed when a profiled candidate reaches `p`; compute them
|
||||
// once for the whole subpath rather than once per candidate.
|
||||
let mut lengths: Option<(Vec<f64>, f64)> = None;
|
||||
for (i, nr) in near {
|
||||
let d = nr.distance_sq.sqrt();
|
||||
let factor = if dash_caps.is_some() {
|
||||
full / half.max(1e-12)
|
||||
} else {
|
||||
self.profile.as_ref().map_or(1.0, |pr| {
|
||||
let (l, rt) = profile_at(pr, &segs, closed, i, nr.t);
|
||||
let (lens, total) = lengths.get_or_insert_with(|| {
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
|
||||
let total = lens.iter().sum::<f64>().max(1e-12);
|
||||
(lens, total)
|
||||
});
|
||||
let (l, rt) = profile_at(pr, &segs, lens, *total, closed, i, nr.t);
|
||||
// Nearest to a segment's end, `p` is beside a join or past a cap, which
|
||||
// spans the widths of both sides.
|
||||
let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9;
|
||||
|
||||
@@ -173,6 +173,18 @@ fn bar(hw: f64) -> f64 {
|
||||
/// Where the notch of the (open) arrow sits, as a fraction of the length.
|
||||
const NOTCH: f64 = 0.7;
|
||||
|
||||
/// How far [`super::stroke_pieces`] trims the line under each end's head: the head's inset from
|
||||
/// the tip, along the path. Both are 0 without a head at that end, and in [`ArrowAlign::Extend`],
|
||||
/// where the stroke keeps its full length.
|
||||
pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) {
|
||||
if st.arrow_align != ArrowAlign::Tip {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let cap = cap_extent(st.cap, st.width);
|
||||
let inset_of = |kind: Option<Arrowhead>, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap));
|
||||
(inset_of(st.start_arrow, false), inset_of(st.end_arrow, true))
|
||||
}
|
||||
|
||||
/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end.
|
||||
fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
|
||||
let l = len(hw);
|
||||
|
||||
@@ -18,11 +18,11 @@ use std::collections::VecDeque;
|
||||
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};
|
||||
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};
|
||||
use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit};
|
||||
use super::{aligned_width, arrow, cap_extent, is_closed, segments, subpaths, unit};
|
||||
|
||||
/// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the
|
||||
/// corner gets a fan of ribs round its outside.
|
||||
@@ -97,7 +97,10 @@ impl Lay<'_> {
|
||||
|
||||
/// How far a rib at fraction `t` reaches either side of the path to cover the stroke there.
|
||||
fn cover(&self, t: f64) -> f64 {
|
||||
self.half(t) + self.margin
|
||||
// A square cap's corners stand √2 of the half-width out from where the dash ends, and a
|
||||
// dash can end just past a corner: reach that far, or they paint outside every slice.
|
||||
let diag = if self.st.dash.is_some() && self.st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
self.half(t) * diag + self.margin
|
||||
}
|
||||
|
||||
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
|
||||
@@ -162,6 +165,9 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
return vec![];
|
||||
}
|
||||
let heads = (st.head_reach(false), st.head_reach(true));
|
||||
// With a tip-aligned arrowhead the stroke's line, and with it the width profile, stops at the
|
||||
// head's inset from the tip (`stroke_pieces`).
|
||||
let trims = arrow::trims(st);
|
||||
let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) };
|
||||
// Caps reach as far past the ends as the stroke is wide there.
|
||||
let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t));
|
||||
@@ -170,27 +176,34 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
let count = subs.len();
|
||||
let mut out = vec![];
|
||||
for (k, (range, closed)) in subs.into_iter().enumerate() {
|
||||
let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4));
|
||||
let side =
|
||||
(st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0)));
|
||||
let segs = segments(&bp.elements()[range]);
|
||||
let flat = flatten(&segs, closed, tol.max(1e-4));
|
||||
let side = (st.align != StrokeAlign::Center && closed_path && closed)
|
||||
.then(|| band_on_left(bp, rule, &flat.pts, st.align, (4.0 * tol).min(hw / 2.0)));
|
||||
// Arrowheads sit on the start of the first subpath and the end of the last one.
|
||||
let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 });
|
||||
let ribs = match pts.len() {
|
||||
let trims = (if k == 0 { trims.0 } else { 0.0 }, if k + 1 == count { trims.1 } else { 0.0 });
|
||||
let ribs = match flat.pts.len() {
|
||||
0 => continue,
|
||||
// A subpath of no length: a dot (round and projecting caps paint it).
|
||||
1 => {
|
||||
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
|
||||
let r = lay.cover(0.0);
|
||||
vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)]
|
||||
vec![Rib::square(flat.pts[0] - d * e, d, r, 0.0, b), Rib::square(flat.pts[0] + d * e, d, r, 0.0, b)]
|
||||
}
|
||||
_ => {
|
||||
let total = length(&pts, closed);
|
||||
let total = length(&flat.pts, closed);
|
||||
if total <= 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 };
|
||||
let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0));
|
||||
let heads = if closed { (0.0, 0.0) } else { heads };
|
||||
// A tip-aligned arrowhead shortens the line it sits on, and the stroke's outline
|
||||
// reads the width profile along the shortened line (`stroke_pieces`); the ribs
|
||||
// must read it there too, or on a profile that changes fast they cover a narrower
|
||||
// band than the stroke paints.
|
||||
let span = if closed { (0.0, total) } else { (trims.0.min(total), (total - trims.1).max(trims.0)) };
|
||||
// Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach
|
||||
// across a head run all the way there instead of narrowing towards the next corner
|
||||
// from the head's tip, which leaves the corners of its base outside (#5964).
|
||||
@@ -200,11 +213,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
.copied()
|
||||
.chain(profile)
|
||||
.chain((1..steps).map(|i| i as f64 / steps as f64))
|
||||
.map(|t| t * total)
|
||||
.map(|t| span.0 + (span.1 - span.0) * t)
|
||||
.chain(head_ends)
|
||||
.collect();
|
||||
let (pts, _) = split_at(&pts, &corner, closed, &at);
|
||||
strip(&lay, &pts, closed, total, side, heads)
|
||||
let (pts, _, _) = split_at(&flat, closed, &at, &segs);
|
||||
strip(&lay, &pts, closed, total, side, heads, span)
|
||||
}
|
||||
};
|
||||
out.push(ribs);
|
||||
@@ -226,8 +239,9 @@ fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign,
|
||||
}
|
||||
|
||||
/// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads
|
||||
/// reach from its start and end).
|
||||
fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>, heads: (f64, f64)) -> Vec<Rib> {
|
||||
/// reach from its start and end). `span` is the stretch of the subpath the width profile is laid
|
||||
/// along: the whole of it, or what is left between tip-aligned arrowheads.
|
||||
fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>, heads: (f64, f64), span: (f64, f64)) -> Vec<Rib> {
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
|
||||
@@ -249,7 +263,9 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
// stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part
|
||||
// reaching that far would paint over a wide part beside it (#5960). The start of a closed
|
||||
// subpath is also its end. Ribs near an arrowhead reach across it.
|
||||
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect();
|
||||
// The fraction of the profile at how far `s` is along the subpath: the profile spans `span`.
|
||||
let t_at = |s: f64| if span.1 > span.0 { ((s - span.0) / (span.1 - span.0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t_at(along[i])) }).collect();
|
||||
let window = match lay.st.dash {
|
||||
Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()),
|
||||
_ => 0.0,
|
||||
@@ -279,7 +295,7 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
|
||||
for i in first..last {
|
||||
let v = i % n;
|
||||
let t = along[v] / total;
|
||||
let t = t_at(along[v]);
|
||||
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side));
|
||||
// A closed subpath starts after the fan of its first corner and ends with it.
|
||||
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
|
||||
|
||||
@@ -232,16 +232,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
|
||||
assert!(near(plain.area().abs(), 1000.0, 1e-6));
|
||||
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
|
||||
// The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714).
|
||||
assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05));
|
||||
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens
|
||||
// rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers
|
||||
// its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192.
|
||||
// The lens is a parabola between its ends: 2/3 of the full-width band.
|
||||
assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1));
|
||||
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0).
|
||||
let st = stroke(10.0, |s| {
|
||||
s.profile = Some(WidthProfile::lens());
|
||||
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
|
||||
});
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05));
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 1000.0 / 3.0, 0.1));
|
||||
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 parabola'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.8)) && !o.contains(Point::new(5.0, 1.2)), "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.4)) && !o.contains(Point::new(10.0, 2.0)));
|
||||
}
|
||||
|
||||
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
|
||||
|
||||
@@ -138,9 +138,8 @@ fn across_runs_from_the_left_edge_to_the_right() {
|
||||
let mut lens = st.clone();
|
||||
lens.profile = Some(WidthProfile::lens());
|
||||
let sl = slices(&line, &lens);
|
||||
// A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is
|
||||
// 3.125 up.
|
||||
assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05);
|
||||
// A quarter of the way along, the lens is three quarters as wide: its top edge is 3.75 up.
|
||||
assert!(param(&sl, Point::new(25.0, -3.6)).unwrap() < 0.05);
|
||||
assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02);
|
||||
}
|
||||
|
||||
|
||||
+789
-187
File diff suppressed because it is too large
Load Diff
@@ -73,14 +73,13 @@ fn arrowheads_are_part_of_the_outline() {
|
||||
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
|
||||
}
|
||||
|
||||
/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle.
|
||||
/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle.
|
||||
#[test]
|
||||
fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() {
|
||||
fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 10, "profile": "lens"}));
|
||||
let n = outline(&mut s);
|
||||
// Each half of the lens swells on a cubic, flat at the middle (#5714).
|
||||
assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -221,9 +221,9 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() {
|
||||
let (mut s, id) = line_session();
|
||||
let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)];
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap();
|
||||
// At t=0.5 the old left side is where the profile's curve is there (#5714) and the right
|
||||
// side is 0pt. Indexed editing of that sampled point is the existing convention: scale the
|
||||
// nonzero side, add from zero.
|
||||
// At t=0.5 the old left side is where the profile's curve is there and the right side is
|
||||
// 0pt. Indexed editing of that sampled point is the existing convention: scale the nonzero
|
||||
// side, add from zero.
|
||||
let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0;
|
||||
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None };
|
||||
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true}));
|
||||
|
||||
@@ -17,6 +17,9 @@ const MAX_BULGE_SAMPLES: f64 = 1024.0;
|
||||
/// About how many points of a long piece its first fit uses.
|
||||
const COARSE_POINTS: usize = 16;
|
||||
|
||||
/// Most times [`fit_through_rec`] splits a run of points, which bounds its recursion.
|
||||
const MAX_SPLITS: usize = 40;
|
||||
|
||||
/// Unit vector or `None` if (nearly) zero.
|
||||
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
|
||||
let l = v.hypot();
|
||||
@@ -37,7 +40,7 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
|
||||
///
|
||||
/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point
|
||||
/// (forward direction). Consecutive output cubics are G1 continuous.
|
||||
pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
pub fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let pts = dedup(pts);
|
||||
if pts.len() < 2 {
|
||||
return Vec::new();
|
||||
@@ -48,6 +51,43 @@ pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<Cub
|
||||
join_pieces(&pts, &pieces, t0, tol)
|
||||
}
|
||||
|
||||
/// Fit a chain of cubics through `pts`, sampled from a smooth curve, with maximum error `tol` at
|
||||
/// the points.
|
||||
///
|
||||
/// Unlike [`fit_cubics`], a piece may leave the chords between the points: the points lie on the
|
||||
/// curve being fitted, so the piece bows with them (each end keeps its tangent, and a two-point
|
||||
/// piece gets handles a third of its chord long). Use this for dense samples of a known curve, and
|
||||
/// [`fit_cubics`] for the samples of a polyline. `t0` and `t1` are as in [`fit_cubics`].
|
||||
pub fn fit_cubics_through(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let pts = dedup(pts);
|
||||
let mut out = Vec::new();
|
||||
if pts.len() < 2 {
|
||||
return out;
|
||||
}
|
||||
fit_through_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Fits all of `pts` with one cubic, splitting at its worst point until each piece is within `tol`
|
||||
/// of its points (see [`fit_cubics_through`]). `depth` bounds the recursion on hostile points.
|
||||
fn fit_through_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>, depth: usize) {
|
||||
let n = pts.len();
|
||||
if n == 2 {
|
||||
let d = pts[0].distance(pts[1]) / 3.0;
|
||||
out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]));
|
||||
return;
|
||||
}
|
||||
let (curve, err, split) = fit_single_tol(pts, t0, t1, tol);
|
||||
if err <= tol || depth >= MAX_SPLITS {
|
||||
out.push(curve);
|
||||
return;
|
||||
}
|
||||
let split = split.clamp(1, n - 2);
|
||||
let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0);
|
||||
fit_through_rec(&pts[..=split], t0, tc, tol, out, depth + 1);
|
||||
fit_through_rec(&pts[split..], tc, t1, tol, out, depth + 1);
|
||||
}
|
||||
|
||||
/// The curves of `pieces`, which follow each other along `pts` from its first point, leaving it
|
||||
/// along `t0`. Each curve takes up to [`MAX_JOINS`] of the pieces after its first one, as long as
|
||||
/// the fit of them together is within `tol`.
|
||||
@@ -152,6 +192,32 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u
|
||||
fit_single_from(pts, chord_params(pts), t0, t1)
|
||||
}
|
||||
|
||||
/// [`fit_single`] that stops reparameterising once the curve is within `tol` of its points: a
|
||||
/// curve already that close needs no better fit, and each Newton round reads every point.
|
||||
fn fit_single_tol(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> (CubicBez, f64, usize) {
|
||||
let mut u = chord_params(pts);
|
||||
let mut best = generate(pts, &u, t0, t1);
|
||||
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
|
||||
for _ in 0..MAX_REPARAM {
|
||||
if best_err <= tol {
|
||||
break;
|
||||
}
|
||||
for (i, ui) in u.iter_mut().enumerate() {
|
||||
*ui = newton(&best, pts[i], *ui);
|
||||
}
|
||||
let c = generate(pts, &u, t0, t1);
|
||||
let (e, s) = max_error(pts, &c, &u);
|
||||
if e < best_err {
|
||||
best = c;
|
||||
best_err = e;
|
||||
best_split = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(best, best_err, best_split)
|
||||
}
|
||||
|
||||
/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the
|
||||
/// chord-length ones.
|
||||
pub(crate) fn fit_single_from(pts: &[Point], u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
@@ -334,6 +400,25 @@ mod tests {
|
||||
assert!(out[0].p1.distance(c.p1) < 1.0, "{:?}", out[0]);
|
||||
}
|
||||
|
||||
/// A curve's samples are followed between the points, not chopped to the chords through
|
||||
/// them: a smooth outline fitted from coarse samples must keep its bow (an outline's area
|
||||
/// depends on it), unlike a polyline's samples.
|
||||
#[test]
|
||||
fn through_fit_bows_between_coarse_points() {
|
||||
// The parabola y = 4t(1 - t) over x = 100t, sampled at 8 points.
|
||||
let node = |t: f64| Point::new(100.0 * t, 4.0 * t * (1.0 - t));
|
||||
let pts: Vec<Point> = (0..=8).map(|i| node(i as f64 / 8.0)).collect();
|
||||
let (t0, t1) = (Vec2::new(1.0, 0.04).normalize(), Vec2::new(1.0, -0.04).normalize());
|
||||
let out = fit_cubics_through(&pts, t0, t1, 0.001);
|
||||
for c in &out {
|
||||
for k in 0..=100 {
|
||||
let q = c.eval(k as f64 / 100.0);
|
||||
let want = 4.0 * (q.x / 100.0) * (1.0 - q.x / 100.0);
|
||||
assert!((q.y - want).abs() < 0.01, "{:?} at {q:?} wants {want}", c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_when_needed() {
|
||||
// A zig-zag cannot be fitted with one cubic.
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use edit::{
|
||||
remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, smooth_options,
|
||||
split_into_grid,
|
||||
};
|
||||
pub use fit::{fit_cubics, fit_cubics_through};
|
||||
pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path};
|
||||
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within};
|
||||
pub use planar::{
|
||||
|
||||
@@ -92,6 +92,11 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() {
|
||||
let near = render(&mut renderer, &d, at);
|
||||
assert_eq!(renderer.strokes.values().any(|e| e.crop.is_some()), cropped, "{name}: crop choice");
|
||||
let full = whole(&d, at);
|
||||
// The cropped outline fits cubics through the same samples, but its fit starts at the
|
||||
// crop rather than at the subpath's end, so a cubic may put its control points
|
||||
// differently (the same curve). The rasteriser then flattens it into a slightly
|
||||
// different polygon, which shows as one level on an anti-aliased edge pixel. Anything
|
||||
// more would be a wrong width.
|
||||
let mismatched: Vec<_> = near
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
@@ -99,7 +104,7 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() {
|
||||
.iter()
|
||||
.zip(full.pixels.as_chunks::<4>().0.iter())
|
||||
.enumerate()
|
||||
.filter(|(_, (a, b))| a != b)
|
||||
.filter(|(_, (a, b))| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 1))
|
||||
.take(8)
|
||||
.map(|(i, (a, b))| (i % SIZE as usize, i / SIZE as usize, a.to_vec(), b.to_vec()))
|
||||
.collect();
|
||||
|
||||
@@ -247,6 +247,8 @@ fn new_width_point_profile_and_compound_target() {
|
||||
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");
|
||||
// The two-point profile is linear: at a quarter its sides are 1.25 and 0.75 of
|
||||
// 1..2 and 0.5..1.5, on a 10pt stroke (5pt half).
|
||||
assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6);
|
||||
assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6);
|
||||
assert!(!d.bool("linked"));
|
||||
|
||||
@@ -1315,7 +1315,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)).
|
||||
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. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand 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-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), 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-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), 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, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on 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, Pressure, Tilt, Bearing or Rotation 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, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; 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, Overlap) 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. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web.
|
||||
Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules.
|
||||
Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).
|
||||
@@ -2183,6 +2183,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). |
|
||||
| 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. |
|
||||
| 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. |
|
||||
| 2026-10-21 | minor | Width-profile outlines offset samples added inside a flattened chord along the source curve's exact tangent, so a zoomed outline no longer jogs at flatten vertices; a regression test covers added samples. Scores and estimates unchanged. |
|
||||
| 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. |
|
||||
| 2026-10-21 | minor | Plug-ins: filters keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own (G17); engine and unit regressions. Scores and hours unchanged. |
|
||||
| 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) |
|
||||
@@ -2199,6 +2200,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
| 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding |
|
||||
| 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged |
|
||||
| 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged |
|
||||
| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. The sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt by default) instead of one cubic per flatten step. On the reviewer's 1,500-ellipse fixture (20–200 pt ellipses, 3–12 pt weights) the PDF falls to 3.59 MB and the SVG to 6.79 MB (reported main baselines 4.48 MB and 9.30 MB, about 0.80× and 0.73×), and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. |
|
||||
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
||||
| 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. |
|
||||
| 2026-10-21 | minor | Print: the Print dialog's Media list carries the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5, which keep their ISO 216 sizes, plus はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; the JSON id or the dialog label names a paper, any case and spacing. Print score and hours unchanged. |
|
||||
@@ -2212,6 +2214,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
| 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