mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 14:20:24 +00:00
M3.64: width profiles end with one-sided PCHIP slopes
This commit is contained in:
@@ -233,9 +233,10 @@ impl WidthProfile {
|
||||
/// 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 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.
|
||||
/// neighbours' neighbours (their slopes depend on it). The ends use the one-sided PCHIP
|
||||
/// slope, or the secant when there are only two points, so a two-point profile stays a
|
||||
/// straight taper. 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) {
|
||||
let (Some(first), Some(last)) = (p.first(), p.last()) else { return (1.0, 1.0) };
|
||||
if t <= first.0 {
|
||||
@@ -306,12 +307,58 @@ impl WidthProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants.
|
||||
/// The caller uses the secant for a run of only two points; a sign change caps the slope, and a
|
||||
/// slope that turns back on the first secant is flattened.
|
||||
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 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).
|
||||
/// secants either side. The profile's first and last points use the one-sided three-point PCHIP
|
||||
/// slope instead (or the secant when their run has only two points), so a two-point profile stays
|
||||
/// a straight taper. Either side of a step, and at a local extremum, the slope is flat.
|
||||
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 {
|
||||
let Some(&(t, _, _)) = p.get(i) else { return 0.0 };
|
||||
if i == 0 {
|
||||
let Some(&(t1, _, _)) = p.get(1) else { return 0.0 };
|
||||
let h0 = t1 - t;
|
||||
if h0 <= 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let d0 = (value(1) - value(0)) / h0;
|
||||
let Some(&(t2, _, _)) = p.get(2) else { return d0 };
|
||||
let h1 = t2 - t1;
|
||||
if h1 <= 1e-12 {
|
||||
return d0;
|
||||
}
|
||||
let d1 = (value(2) - value(1)) / h1;
|
||||
return end_slope(h0, h1, d0, d1);
|
||||
}
|
||||
if i + 1 == p.len() {
|
||||
let Some(&(tm, _, _)) = i.checked_sub(1).and_then(|k| p.get(k)) else { return 0.0 };
|
||||
let h0 = t - tm;
|
||||
if h0 <= 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let d0 = (value(i) - value(i - 1)) / h0;
|
||||
let Some(&(tmm, _, _)) = i.checked_sub(2).and_then(|k| p.get(k)) else { return d0 };
|
||||
let h1 = tm - tmm;
|
||||
if h1 <= 1e-12 {
|
||||
return d0;
|
||||
}
|
||||
let d1 = (value(i - 1) - value(i - 2)) / h1;
|
||||
return end_slope(h0, h1, d0, d1);
|
||||
}
|
||||
let (Some(&(tm, _, _)), Some(&(tp, _, _))) = (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);
|
||||
@@ -1340,7 +1387,7 @@ mod tests {
|
||||
fn profile_interp() {
|
||||
let p = WidthProfile::lens();
|
||||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||||
assert_eq!(p.at(0.25), (0.5, 0.5));
|
||||
assert_eq!(p.at(0.25), (0.75, 0.75));
|
||||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
@@ -1363,20 +1410,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-point profile is a smoothstep: it eases out of and into the constant ends, and its
|
||||
/// midpoint is still the linear one.
|
||||
/// 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_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;
|
||||
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 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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,14 +153,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||
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 smoothed sides are cubics through the profile's samples: 0.06 pt² off the polygon.
|
||||
assert!(near(lens.area().abs(), 500.0, 0.1));
|
||||
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): the dash over
|
||||
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
|
||||
// 0..25 averages 5/12 of the width, the one over 50..75 11/12.
|
||||
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(), 62.5 + 187.5, 1e-6));
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -156,9 +156,9 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
|
||||
});
|
||||
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,
|
||||
// where the smooth profile is thinner than a straight ramp (a smoothstep's first tenth).
|
||||
// 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, 0.5)) && o.contains(Point::new(5.0, 0.1)), "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| {
|
||||
@@ -167,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.4)));
|
||||
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).
|
||||
|
||||
@@ -137,8 +137,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 half as wide: its top edge is 2.5 up.
|
||||
assert!(param(&sl, Point::new(25.0, -2.4)).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);
|
||||
}
|
||||
|
||||
|
||||
@@ -657,12 +657,12 @@ 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.
|
||||
/// The lens eases up to full width and back down (a parabolic bump averaging two thirds of
|
||||
/// the width); a one-sided profile keeps its side at zero and stays on the other.
|
||||
#[test]
|
||||
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() < 0.1, "{a}");
|
||||
assert!((a - 2000.0 / 3.0).abs() < 0.1, "{a}");
|
||||
let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] };
|
||||
let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01);
|
||||
assert!((area(&o) - 500.0).abs() < 1e-6);
|
||||
|
||||
@@ -72,11 +72,11 @@ fn arrowheads_are_part_of_the_outline() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lens_profile_outlines_to_half_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);
|
||||
assert!(near(area(&n), 0.5 * 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]
|
||||
|
||||
@@ -374,10 +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");
|
||||
// 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);
|
||||
// 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"));
|
||||
d.fields.insert("side1".into(), json!(8));
|
||||
d.fields.insert("side2".into(), json!(3));
|
||||
|
||||
@@ -764,7 +764,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; 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.
|
||||
- **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, with the one-sided three-point PCHIP slope at the profile's ends and flat 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.
|
||||
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 states that "The colors that a scatter, art, or pattern brush paints depend on the current stroke color and the colorization method of the brush" (<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).
|
||||
Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value.
|
||||
|
||||
Reference in New Issue
Block a user