effects: offset profile samples along the source tangent

Profile samples added inside a flattened chord used the chord's direction for their normal, so a zoomed outline jogged at every flatten vertex. Keep the source segment that drew each flattened piece and ask it for the exact tangent at added samples; fit runs with coordinate-wise Fritsch-Carlson tangents. Adds a regression test and records the change in the parity evidence.
This commit is contained in:
Adam
2026-10-21 03:24:01 +02:00
parent 67933d404f
commit 2c363d6b88
3 changed files with 219 additions and 93 deletions
+8 -7
View File
@@ -158,20 +158,21 @@ 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 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));
vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)]
vec![Rib::square(flat.pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(flat.pts[0] + d * e, d, lay.cover, 0.0, b)]
}
_ => {
let total = length(&pts, closed);
let total = length(&flat.pts, closed);
if total <= 1e-12 {
continue;
}
@@ -179,7 +180,7 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0));
let at: Vec<f64> =
marks.iter().copied().chain(profile).chain((1..steps).map(|i| i as f64 / steps as f64)).map(|t| t * total).collect();
let (pts, _) = split_at(&pts, &corner, closed, &at);
let (pts, _, _) = split_at(&flat, closed, &at, &segs);
strip(&lay, &pts, closed, total, side, if closed { (0.0, 0.0) } else { heads })
}
};
+209 -85
View File
@@ -10,10 +10,10 @@
use std::f64::consts::PI;
use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2};
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::{kink, segments, subpaths, unit};
use super::{kink, segments, subpaths, tangent, unit};
/// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter
/// limit of `st`, flattened to `tol`.
@@ -36,9 +36,10 @@ pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, prof
};
let mut out = BezPath::new();
for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() {
let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol);
if pts.len() > 1 {
ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0)));
let segs = segments(&bp.elements()[r]);
let flat = flatten(&segs, closed, ctx.tol);
if flat.pts.len() > 1 {
ctx.subpath(&mut out, &flat, &segs, closed, spans.get(i).copied().unwrap_or((0.0, 1.0)));
}
}
out
@@ -49,19 +50,29 @@ pub(super) fn left(t: Vec2) -> Vec2 {
Vec2::new(t.y, -t.x)
}
/// A flattened subpath: its points, which of them are corners, and the source segment that drew
/// each flattened segment (so a sample added between two points can read that segment's tangent
/// instead of the chord's direction).
pub(super) struct Flat {
pub(super) pts: Vec<Point>,
pub(super) corner: Vec<bool>,
pub(super) seg_of: Vec<usize>,
}
/// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't
/// repeat its start), each marked when it is a corner of the path.
pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>, Vec<bool>) {
let Some(s0) = segs.first() else { return (vec![], vec![]) };
let (mut pts, mut corner) = (vec![s0.start()], vec![false]);
pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat {
let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![] } };
let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![] };
// The first and the last segment that drew something.
let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None);
for seg in segs {
let at = pts.len() - 1;
for (si, seg) in segs.iter().enumerate() {
let at = flat.pts.len() - 1;
let before = flat.pts.len();
let mut push = |p: Point| {
if pts.last().is_none_or(|l| l.distance(p) > 1e-9) {
pts.push(p);
corner.push(false);
if flat.pts.last().is_none_or(|l| l.distance(p) > 1e-9) {
flat.pts.push(p);
flat.corner.push(false);
}
};
match seg {
@@ -72,45 +83,57 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>,
}
}),
}
if pts.len() > at + 1 {
match prev {
Some(p) => corner[at] = kink(p, seg),
None => first = Some(seg),
if flat.pts.len() > before {
let end = flat.pts.len() - 1;
flat.seg_of.resize(end, si);
for slot in flat.seg_of.iter_mut().take(end).skip(at) {
*slot = si;
}
if let Some(p) = prev {
flat.corner[at] = kink(p, seg);
} else {
first = Some(seg);
}
prev = Some(seg);
}
}
if closed {
if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 {
pts.pop();
corner.pop();
if flat.pts.len() > 1 && flat.pts[0].distance(flat.pts[flat.pts.len() - 1]) <= 1e-9 {
flat.pts.pop();
flat.corner.pop();
}
if let (Some(a), Some(b)) = (prev, first) {
corner[0] = kink(a, b);
flat.corner[0] = kink(a, b);
}
// A closing segment normally came from the path's last source segment; keep a tangent for
// it if the path closed without a duplicate point.
if flat.seg_of.len() < flat.pts.len() {
let last = flat.seg_of.last().copied().unwrap_or(0);
flat.seg_of.resize(flat.pts.len(), last);
}
}
(pts, corner)
flat
}
/// 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>) {
fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec<Point>, Vec<bool>, Vec<Vec2>) {
// 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 total = length(&flat.pts, closed);
let range = span.1 - span.0;
if total <= 1e-12 || range <= 1e-12 {
return (pts.to_vec(), corner.to_vec());
return split_at(flat, closed, &[], segs);
}
// Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
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 constant = 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 constant { 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 {
@@ -121,7 +144,7 @@ fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile,
marks.push(last);
}
let marks: Vec<f64> = marks.into_iter().map(|t| (t - span.0) / range * total).collect();
split_at(pts, corner, closed, &marks)
split_at(flat, closed, &marks, segs)
}
/// Length of a flattened subpath (see [`flatten`]).
@@ -133,28 +156,55 @@ pub(super) fn length(pts: &[Point], closed: bool) -> f64 {
/// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its
/// start; those on a point or off the subpath are ignored).
pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec<Point>, Vec<bool>) {
pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg]) -> (Vec<Point>, Vec<bool>, Vec<Vec2>) {
let Flat { pts, corner, seg_of } = flat;
let n = pts.len();
let seg_count = if closed { n } else { n.saturating_sub(1) };
let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()));
// The source's direction at `p`, from the segment that drew its flattened piece. The chord
// direction is the secant, not the tangent: using it for points added inside a chord jogs the
// normal at every flatten vertex.
let source = |p: Point, si: Option<usize>| -> Vec2 {
let Some(seg) = si.and_then(|i| segs.get(i)) else { return Vec2::new(1.0, 0.0) };
tangent(seg, seg.nearest(p, 1e-9).t)
};
// At a shared point, average the arriving and leaving directions unless it is a corner.
let dir_at = |i: usize| -> Vec2 {
let incoming = if i == 0 { if closed { seg_of.last().copied() } else { None } } else { seg_of.get(i - 1).copied() };
let outgoing = seg_of.get(i).copied();
let (a, b) = (incoming.map(|si| source(pts[i], Some(si))), outgoing.map(|si| source(pts[i], Some(si))));
match (a, b) {
(Some(a), Some(b)) if !corner[i] => {
let sum = a + b;
if sum.hypot() > 1e-9 { unit(sum) } else { b }
}
(_, Some(b)) => b,
(Some(a), None) => a,
(None, None) => Vec2::new(1.0, 0.0),
}
};
let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()));
let mut acc = 0.0;
for i in 0..seg_count {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let len = a.distance(b);
out.0.push(a);
out.1.push(corner[i]);
out.2.push(dir_at(i));
let mut inner: Vec<f64> = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect();
inner.sort_by(f64::total_cmp);
inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
for d in inner {
out.0.push(a.lerp(b, (d - acc) / len));
let p = a.lerp(b, (d - acc) / len);
out.0.push(p);
out.1.push(false);
out.2.push(source(p, seg_of.get(i).copied()));
}
acc += len;
}
if !closed && n > 0 {
out.0.push(pts[n - 1]);
out.1.push(corner[n - 1]);
out.2.push(dir_at(n - 1));
}
out
}
@@ -177,14 +227,19 @@ struct Vertex {
p: Point,
a: Vec2,
b: Vec2,
/// The smooth tangent: the source curve's direction at this sample, or the average of its
/// arriving and leaving directions at a smooth join. A smooth point offsets along this
/// normal, not the chord bisector; otherwise the chord normal inside each flattened piece and
/// the bisector at each flatten vertex alternate and the edge is scalloped.
n: Vec2,
la: f64,
lb: f64,
corner: bool,
}
impl Ctx<'_> {
fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) {
let (pts, corner) = densify(pts, corner, closed, self.profile, span);
fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64)) {
let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span);
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let seg_dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
@@ -217,14 +272,14 @@ impl Ctx<'_> {
for (i, &p) in pts.iter().enumerate() {
let ((wl_b, wr_b), (wl_a, wr_a)) = width_at(cum[i]);
if !closed && (i == 0 || i == n - 1) {
let nm = left(seg_dir[i.min(seg_count - 1)]);
let nm = left(dirs[i]);
let (wl, wr) = if i == 0 { (wl_a, wr_a) } else { (wl_b, wr_b) };
lpts.push((p + nm * wl, end_sharp));
rpts.push((p - nm * wr, end_sharp));
continue;
}
let ip = if i == 0 { seg_count - 1 } else { i - 1 };
let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] };
let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], n: dirs[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] };
// A step: the side goes straight out (or in) from the width before to the one after.
// Both ends of that straight piece are corners: the run before it must not round
// into the step.
@@ -243,7 +298,7 @@ impl Ctx<'_> {
polygon(out, rpts.into_iter().rev(), self.tol);
return;
}
let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]);
let (t0, t1) = (dirs[0], dirs[n - 1]);
let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0);
let mut ring = lpts;
// End cap: from the left side around the end to the right side.
@@ -272,19 +327,16 @@ impl Ctx<'_> {
return;
}
let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None };
// The offset of the smooth path itself is at `w` along the vertex normal. The polyline
// The offset of the smooth path itself is at `w` along the path normal. The polyline
// miter (`w / c`) is the offset of the flattened segments, not of the curve: at a tight
// curve it spiked every sample outward and left a scalloped edge. A round join would dent
// it inward instead; the unit bisector is the smooth normal.
let normal = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c))) } else { None };
// curve it spiked every sample outward and left a scalloped edge. The source curve's
// tangent is the smooth normal; a corner still uses its two chords for the join.
let smooth = v.p + left(v.n) * (w * side);
if v.b.dot(na) <= 0.0 {
// Outer side: the path turns away from it.
if !v.corner {
// A smooth point has no join: its offset is along the vertex normal.
match normal {
Some(n) => pts.push((n, step)),
None => self.arc(pts, v, na, nb, w, step),
}
// A smooth point has no join: its offset is along the path normal.
pts.push((smooth, step));
} else {
let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY };
match self.join {
@@ -308,12 +360,11 @@ impl Ctx<'_> {
let turn = 2.0 * c.clamp(-1.0, 1.0).acos();
let radius = if turn > 1e-9 { 0.5 * (v.la + v.lb) / turn } else { f64::INFINITY };
if self.uniform && w >= radius {
pts.push((v.p + na * w, true));
pts.push((smooth, true));
pts.push((v.p, true));
pts.push((v.p + nb * w, true));
pts.push((smooth, true));
} else {
let n = normal.unwrap_or(v.p);
pts.push((n, step));
pts.push((smooth, step));
}
} else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) {
// A corner's inner offset is its miter while that stays inside both segments.
@@ -438,44 +489,58 @@ fn polygon(out: &mut BezPath, pts: impl Iterator<Item = (Point, bool)>, tol: f64
out.close_path();
}
/// The tangent at the start of the segment `run[i] → run[j]`, for that segment's parameter. This
/// is the chord-length Catmull–Rom tangent: the secant across the point's neighbours, scaled by
/// the segment's share of the neighbouring lengths. The share keeps a short sample from flattening
/// the long segment beside it, while the secant's own length (not a unit direction) keeps a curved
/// run from overshooting. At an end, the segment itself.
fn tangent_start(run: &[Point], i: usize, j: usize, cyclic: bool) -> Vec2 {
/// The derivative of the run at each point, in points per point of chord length. Each coordinate
/// is a shape-preserving cubic Hermite (Fritsch–Carlson): between two points it stays within their
/// coordinate range, so a cubic through a fast width pinch cannot bulge past the pinch and cross
/// the other side, and it has a flat slope at a local extremum. A Catmull–Rom tangent (the secant
/// across the neighbours) does neither: it overshoots a V and, with the uneven sample spacing of a
/// flattened path, leaves a scalloped edge.
fn tangents(run: &[Point], cyclic: bool) -> Vec<Vec2> {
let n = run.len();
let (p, q) = (run[i], run[j]);
let l = p.distance(q);
if l <= 1e-12 {
return Vec2::ZERO;
let segs = if cyclic { n } else { n.saturating_sub(1) };
let mut h = vec![0.0; segs];
for (i, hi) in h.iter_mut().enumerate() {
*hi = run[i].distance(run[(i + 1) % n]);
}
if cyclic || i > 0 {
let prev = run[(i + n - 1) % n];
let la = p.distance(prev);
if la + l > 1e-12 {
return (q - prev) * (l / (la + l));
let slope = |d0: f64, d1: f64, h0: f64, h1: f64| {
if d0 * d1 <= 0.0 {
return 0.0;
}
let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0);
let m = (w0 + w1) / (w0 / d0 + w1 / d1);
if m.is_finite() { m } else { 0.0 }
};
let delta = |i: usize| -> Vec2 {
let hi = h[i % segs];
if hi > 1e-12 { (run[(i + 1) % n] - run[i % n]) / hi } else { Vec2::ZERO }
};
let mut out = vec![Vec2::ZERO; n];
for (i, m) in out.iter_mut().enumerate() {
let (d0, d1) = (delta((i + n - 1) % n), delta(i % segs));
let (h0, h1) = (h[(i + n - 1) % segs], h[i % segs]);
if h0 <= 1e-12 || h1 <= 1e-12 {
*m = if h1 > 1e-12 { d1 } else { d0 };
} else if cyclic || (i > 0 && i + 1 < n) {
*m = Vec2::new(slope(d0.x, d1.x, h0, h1), slope(d0.y, d1.y, h0, h1));
} else if i == 0 {
*m = d1;
} else {
*m = d0;
}
// A shape-preserving slope is flat in a coordinate at that coordinate's local extremum.
// Where both are extrema at one sample (a loop or a pinch in the sampled edge) the flat
// tangent would make a line and a kink; the secant across the sample keeps the curve C1
// there, and the extremum itself is what trims the loop later.
if m.hypot() <= 1e-12 {
let prev = if cyclic || i > 0 { run[(i + n - 1) % n] } else { run[i] };
let next = if cyclic || i + 1 < n { run[(i + 1) % n] } else { run[i] };
let sec = next - prev;
if sec.hypot() > 1e-12 {
*m = sec.normalize();
}
}
}
q - p
}
/// The tangent at the end of the segment `run[i] → run[j]`, pointing forward.
fn tangent_end(run: &[Point], i: usize, j: usize, cyclic: bool) -> Vec2 {
let n = run.len();
let (p, q) = (run[i], run[j]);
let l = p.distance(q);
if l <= 1e-12 {
return Vec2::ZERO;
}
if cyclic || j + 1 < n {
let next = run[(j + 1) % n];
let lb = q.distance(next);
if l + lb > 1e-12 {
return (next - p) * (l / (l + lb));
}
}
q - p
out
}
/// Append a run of points as cubics through them (or one line when they are collinear).
@@ -489,13 +554,15 @@ fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) {
return;
}
let segs = if cyclic { n } else { n - 1 };
let tans = tangents(run, cyclic);
for i in 0..segs {
let j = (i + 1) % n;
let (m0, m1) = (tangent_start(run, i, j, cyclic), tangent_end(run, i, j, cyclic));
let h = run[i].distance(run[j]);
let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0));
if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 {
out.line_to(run[j]);
} else {
out.curve_to(run[i] + m0 / 3.0, run[j] - m1 / 3.0, run[j]);
out.curve_to(run[i] + m0, run[j] - m1, run[j]);
}
}
}
@@ -594,6 +661,33 @@ mod tests {
assert!(step, "{o:?}");
}
#[test]
fn added_samples_follow_the_curve_not_the_flattened_chord() {
// A cubic with a tight S: its chords are far from its tangents. A sample inserted inside a
// chord must take the curve's direction there; the chord's direction is what made a wide
// profile scallop at every flatten vertex.
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
b.curve_to((0.0, 120.0), (120.0, -120.0), (120.0, 0.0));
let segs = segments(b.elements());
let flat = flatten(&segs, false, 2.0);
assert!(flat.pts.len() >= 3);
let mut marks = vec![];
let mut acc = 0.0;
for i in 0..flat.pts.len() - 1 {
let len = flat.pts[i].distance(flat.pts[i + 1]);
marks.push(acc + len / 2.0);
acc += len;
}
let (all, _, dirs) = split_at(&flat, false, &marks, &segs);
assert!(all.len() > flat.pts.len());
let seg = segs[0];
for (p, d) in all.iter().zip(&dirs) {
let want = tangent(&seg, seg.nearest(*p, 1e-9).t);
assert!(d.dot(want) > 0.999, "{d:?} vs {want:?} at {p:?}");
}
}
#[test]
fn a_wiggly_profile_on_a_curve_fits_without_kinks() {
// A rapid width change turns the offset sharply; that is not a path corner, so the
@@ -677,6 +771,36 @@ mod tests {
assert!(o.contains(Point::new(0.0, 0.0)), "{o:?}");
}
#[test]
fn a_quick_width_change_does_not_overshoot_its_samples() {
// Width falls to a point and comes back, unevenly on either side: the fitted edge must not
// dip past the point (that crossed the other edge and left a scalloped neck).
let mut run = vec![Point::new(0.0, 0.0), Point::new(10.0, -1.0), Point::new(20.0, -0.3)];
let (mut a, mut b) = (BezPath::new(), BezPath::new());
a.move_to(run[0]);
b.move_to(run[0]);
smooth_run(&mut a, &run, false, 0.0);
// Reverse the same points: the fit must stay inside the V from either direction too.
run.reverse();
smooth_run(&mut b, &run, false, 0.0);
for path in [a, b] {
let mut start = Point::ZERO;
for el in path.elements() {
match el {
PathEl::MoveTo(p) => start = *p,
PathEl::CurveTo(c1, c2, p) => {
for c in [c1, c2] {
assert!(c.y >= -1.0 - 1e-9, "{c:?} in {path:?}");
assert!(c.y <= start.y.max(p.y) + 1e-9, "{c:?} in {path:?}");
}
start = *p;
}
_ => {}
}
}
}
}
#[test]
fn a_coarse_wide_circle_keeps_its_offset() {
// A wide stroke flattened coarsely: offsetting each flattened segment by its miter
+2 -1
View File
@@ -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. 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 reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step.
- **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; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; 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 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.
@@ -851,6 +851,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect
| Date | Change | Summary |
|---|---|---|
| 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-19 | minor | Plug-ins: live effects are given their object's fills and strokes, read only, and the item they sit on (G17); engine and unit regressions. Scores and hours unchanged. |
| 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. |