From a5541ab42a5358cf24767723bd7c717caf280c2c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:26:32 -0800 Subject: [PATCH] Blend: steps of brushed strokes interpolate the pen pressure, tilt and angles A blend between two strokes drawn with a pressure-sensitive brush gave every step the pen recording of the nearer key, so the brush jumped at the middle step. The steps now interpolate the pressure, tilt, bearing and rotation at every position either key has a sample. A key without a recording counts as half pressure, half tilt and 0 degrees, as brushes read it. Angles turn the shorter way round, and steps and whole turns in a recording stay in the steps. Different brushes still switch halfway. --- crates/doc/src/appearance/profile.rs | 7 ++ crates/doc/src/blend.rs | 164 ++++++++++++++++++++++++++- docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 4 files changed, 171 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 407780177e..3d89615016 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -202,6 +202,13 @@ impl AngleProfile { if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 } } + /// The angle at `t` (0..1) as [`Self::at`] reads it, but in the samples' own running degrees, + /// so the whole turns the curve makes along the stroke are kept; 0 without any samples. + pub fn unwrapped_at(&self, t: f64) -> f64 { + let v = interpolated(&self.points, t).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + } + /// A profile from (position, degrees) samples in order along the stroke: non-finite ones are /// dropped, each sample is joined to the one before it the shorter way round, and the curve is /// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample. diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 4e51777060..d7b41079f1 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -5,7 +5,8 @@ //! winding and to the start points that keep them from twisting, or to the anchors the Blend //! tool clicked. //! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ), -//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't +//! opacity, stroke weight, dashes, miter limit, width profiles and the pen pressure, tilt, +//! bearing and rotation that brushes read interpolate; what can't //! (caps, joins, brushes, fonts, symbols, text content) switches halfway. //! - **Groups** pair their children in stacking order; the children one group has more of grow //! out of the other group's centre. Compound paths stay compound paths. @@ -22,7 +23,7 @@ use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; -use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; +use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; use crate::node::{Node, NodeId, NodeKind}; use crate::text::TextObject; @@ -250,9 +251,101 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O Some(WidthProfile { points }) } -/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width -/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and -/// brushes switch halfway). +/// The positions along the stroke where either of two pen recordings has a sample, in order. +fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec { + let mut at: Vec = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect(); + at.sort_by(f64::total_cmp); + at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + if at.is_empty() { + at = vec![0.0, 1.0]; + } + at +} + +/// The value pen recording `p` steps to at position `f`, where two or more of its samples share +/// that position (reading at `f` gives the first of them); None where it doesn't step. +fn step_after(p: Option<&[(f64, f64)]>, f: f64) -> Option { + let p = p?; + let i = p.partition_point(|q| q.0 < f - 1e-9); + let j = p.partition_point(|q| q.0 <= f + 1e-9).checked_sub(1)?; + (j > i && (p.get(i)?.0 - f).abs() <= 1e-9).then(|| p.get(j).map(|q| q.1)).flatten() +} + +/// Two pen recordings read at every position either has a sample, as (position, x's value, y's +/// value): `read_x` and `read_y` read them, and `clean` makes a stepped sample's value one they +/// would read. A position where either steps gives two, before and after the step. +fn paired_samples( + x: Option<&[(f64, f64)]>, + y: Option<&[(f64, f64)]>, + read_x: impl Fn(f64) -> f64, + read_y: impl Fn(f64) -> f64, + clean: impl Fn(f64) -> f64, +) -> Vec<(f64, f64, f64)> { + let mut out = Vec::new(); + for f in sample_positions(x, y) { + let (a, b) = (read_x(f), read_y(f)); + out.push((f, a, b)); + let (sa, sb) = (step_after(x, f), step_after(y, f)); + if sa.is_some() || sb.is_some() { + out.push((f, sa.map_or(a, &clean), sb.map_or(b, &clean))); + } + } + out +} + +/// Interpolate pen pressure or tilt recordings at every position either has a sample, keeping +/// their steps. A stroke without one reads as [`PressureProfile::MID`], as brushes read it; None +/// when neither has one. +fn lerp_pressure(x: Option<&PressureProfile>, y: Option<&PressureProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let read = |p: Option<&PressureProfile>, f: f64| p.map_or(PressureProfile::MID, |p| p.at(f)); + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { PressureProfile::MID }; + let samples = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean); + PressureProfile::from_samples(samples.into_iter().map(|(f, a, b)| (f, lerp_f64(a, b, t)))) +} + +/// Interpolate pen angle recordings (bearing or barrel rotation) at every position either has a +/// sample: the steps turn from one key's angle to the other's the shorter way round at the +/// stroke's start, and keep the whole turns each recording makes along the stroke. A stroke +/// without one reads as 0°, as brushes read it; None when neither has one. +fn lerp_angle(x: Option<&AngleProfile>, y: Option<&AngleProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let read = |p: Option<&AngleProfile>, f: f64| p.map_or(0.0, |p| p.unwrapped_at(f)); + let clean = |v: f64| if v.is_finite() { v } else { 0.0 }; + let pairs = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean); + // Whole turns that bring the two angles within half a turn of each other at the first position. + let d0 = pairs.first().map_or(0.0, |&(_, a, b)| b - a); + let turns = vectorcraft_geom::normalize_deg(d0) - d0; + let value = |a: f64, b: f64| a + (b - a + turns) * t; + // Both curves are straight between two positions; samples are added where the steps turn + // through 90° or more between them, since `AngleProfile::from_samples` joins neighbours the + // shorter way round. A document's recordings can be long, so the added samples are limited. + let mut budget = 4 * PressureProfile::MAX_POINTS; + let mut samples = Vec::with_capacity(pairs.len()); + let mut prev: Option<(f64, f64)> = None; + for (f, a, b) in pairs { + let v = value(a, b); + if let Some((pf, pv)) = prev.filter(|p| f - p.0 > 1e-9) { + let parts = ((v - pv).abs() / 90.0).ceil().min((budget + 1) as f64) as usize; + for k in 1..parts { + let g = pf + (f - pf) * k as f64 / parts as f64; + samples.push((g, value(read(x, g), read(y, g)))); + } + budget -= parts.saturating_sub(1).min(budget); + } + samples.push((f, v)); + prev = Some((f, v)); + } + AngleProfile::from_samples(samples) +} + +/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale, width +/// profile and the pen pressure, tilt, bearing and rotation its brush reads into `o` (a copy of +/// the nearer stroke, so caps, joins, alignment, arrowheads and brushes switch halfway). fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { let tf = t as f32; o.paint = lerp_paint(&x.paint, &y.paint, tf); @@ -262,6 +355,10 @@ fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t); o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t)); o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t); + o.pressure = lerp_pressure(x.pressure.as_ref(), y.pressure.as_ref(), t); + o.tilt = lerp_pressure(x.tilt.as_ref(), y.tilt.as_ref(), t); + o.bearing = lerp_angle(x.bearing.as_ref(), y.bearing.as_ref(), t); + o.rotation = lerp_angle(x.rotation.as_ref(), y.rotation.as_ref(), t); } /// Interpolate appearance stacks: item by item when their structure matches, else the top fill @@ -1731,6 +1828,63 @@ mod tests { assert_eq!(p.at(0.5), (1.0, 1.0)); } + #[test] + fn pen_pressure_tilt_and_angles_interpolate_for_brushes() { + let stroked = |pressure: Option, tilt: Option, bearing: Option| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + s.brush = Some("Pressure Round".into()); + s.pressure = pressure.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] }); + s.tilt = tilt.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] }); + s.bearing = bearing.map(|a| AngleProfile { points: vec![(0.0, a), (1.0, a)] }); + } + ap + }; + let at = |ap: &Appearance, f: f64| { + let s = ap.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let read = |p: Option<&PressureProfile>| p.map_or(PressureProfile::MID, |p| p.at(f)); + (read(s.pressure.as_ref()), read(s.tilt.as_ref()), s.bearing.as_ref().map_or(0.0, |p| p.at(f))) + }; + let (light, heavy) = (stroked(Some(0.0), Some(0.2), Some(170.0)), stroked(Some(1.0), Some(0.6), Some(-170.0))); + let (p, tilt, bearing) = at(&lerp_appearance(&light, &heavy, 0.25), 0.5); + assert!((p - 0.25).abs() < 1e-9 && (tilt - 0.3).abs() < 1e-9, "a quarter of the way: {p} {tilt}"); + assert!((bearing - 175.0).abs() < 1e-9, "the angle turns the shorter way round, through 180°: {bearing}"); + assert!((at(&lerp_appearance(&light, &heavy, 0.5), 0.5).2.abs() - 180.0).abs() < 1e-9); + // A stroke drawn without a recording reads as half pressure, half tilt and 0°. + let plain = stroked(None, None, None); + let (p, tilt, bearing) = at(&lerp_appearance(&heavy, &plain, 0.5), 0.0); + assert!((p - 0.75).abs() < 1e-9 && (tilt - 0.55).abs() < 1e-9 && (bearing + 85.0).abs() < 1e-9, "{p} {tilt} {bearing}"); + // Profiles that vary along the stroke interpolate at each position. + let mut ramp = stroked(None, None, None); + if let Some(s) = ramp.stroke_mut() { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + } + let mid = lerp_appearance(&ramp, &heavy, 0.5); + assert!((at(&mid, 0.0).0 - 0.5).abs() < 1e-9 && (at(&mid, 0.5).0 - 0.75).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure)); + // A step in a recording (two samples at one position) stays a step. + let mut stepped = stroked(None, None, None); + if let Some(s) = stepped.stroke_mut() { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }); + } + let mid = lerp_appearance(&stepped, &stepped, 0.5); + assert!(at(&mid, 0.25).0.abs() < 1e-9 && (at(&mid, 0.75).0 - 1.0).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure)); + // A whole turn recorded along a stroke stays a whole turn in the steps. + let mut turning = stroked(None, None, None); + if let Some(s) = turning.stroke_mut() { + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + } + let rot = |ap: &Appearance, f: f64| ap.stroke().and_then(|s| s.rotation.as_ref()).map_or(0.0, |p| p.at(f)); + let same = lerp_appearance(&turning, &turning, 0.5); + assert!((rot(&same, 0.25) - 90.0).abs() < 1e-9 && (rot(&same, 0.5).abs() - 180.0).abs() < 1e-9, "{:?}", same.stroke().map(|s| &s.rotation)); + let half = lerp_appearance(&turning, &plain, 0.5); + assert!((rot(&half, 0.5) - 90.0).abs() < 1e-9 && (rot(&half, 1.0).abs() - 180.0).abs() < 1e-9, "{:?}", half.stroke().map(|s| &s.rotation)); + // Neither key has a recording: the steps have none either. + let none = lerp_appearance(&plain, &plain, 0.5); + let s = none.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none()); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 996908799f..ee40e202c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8938,8 +8938,10 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p ``` How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes -interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale -and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with +interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale, +width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a +recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round), while +caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2962c63b7e..0bd1e51d6b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1014,6 +1014,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). + - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.