Merge pull request #6093 from slafebre/fix/5960-gradient-along-crossings

Fix #5960: a narrow part of a variable-width stroke paints over a wide part only where they overlap
This commit is contained in:
BFlat
2026-11-09 03:17:26 +08:00
committed by GitHub
3 changed files with 216 additions and 20 deletions
+103 -19
View File
@@ -13,6 +13,8 @@
//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh
//! ([`gradient_meshes`]).
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};
@@ -78,14 +80,26 @@ struct Lay<'a> {
st: &'a StrokeLayer,
/// Half the stroked width ([`aligned_width`]).
hw: f64,
/// How far ribs reach either side of the path where nothing wider is near.
cover: f64,
/// How far ribs reach past the stroke's edge.
margin: f64,
/// How far a strip runs on past the start and end of an open subpath (caps and heads).
ext: (f64, f64),
}
impl Lay<'_> {
/// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider
/// side, just before or just after a step there.
fn half(&self, t: f64) -> f64 {
let Some(p) = &self.st.profile else { return self.hw };
let ((bl, br), (al, ar)) = p.around(t);
self.hw * bl.max(br).max(al).max(ar).max(0.0)
}
/// 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
}
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
/// left; `None`: both, a centred stroke).
fn band(&self, t: f64, side: Option<bool>) -> (f64, f64) {
@@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
if !(hw > 0.0 && hw.is_finite()) {
return vec![];
}
let full = hw * st.profile_max();
let heads = (st.head_reach(false), st.head_reach(true));
let cap = cap_extent(st.cap, 2.0 * full);
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
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));
lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin);
let subs = subpaths(bp);
let count = subs.len();
let mut out = vec![];
@@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
// 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)]
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)]
}
_ => {
let total = length(&pts, closed);
@@ -215,9 +231,37 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
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();
// Ribs near an arrowhead reach across it.
let reach = |s: f64| {
let mut r = lay.cover;
// How far each point is from the next, and along the subpath.
let seg: Vec<f64> = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect();
let along: Vec<f64> = seg
.iter()
.scan(0.0, |s, l| {
let at = *s;
*s += l;
Some(at)
})
.collect();
// How far the ribs at point `i` reach: past the stroke there and on its way to the points
// either side, where it is no wider than at one of the two (each side of a width profile
// stays between its widths at the points of a span, and its points have points here). The
// caps and dots of a dashed stroke reach along the path as far as it is wide where they sit,
// so there the ribs cover the widest the stroke gets that far either way. Not as far as the
// 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();
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,
};
let ahead = widest_ahead(&cover, &seg, closed, window);
// Behind a point is ahead of it on the subpath run backwards.
let back_seg: Vec<f64> = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect();
let back_cover: Vec<f64> = cover.iter().rev().copied().collect();
let behind: Vec<f64> = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect();
let reach = |i: usize| {
let mut r = cover[i].max(ahead[i]).max(behind[i]);
let s = along[i];
if heads.0 > 0.0 && s <= heads.0 {
r = r.max(heads.0 + lay.margin);
}
@@ -227,29 +271,69 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
r
};
let mut out = Vec::with_capacity(n + 4);
let mut s = 0.0;
if !closed {
let (d, b) = (dir[0], lay.band(0.0, side));
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b));
s = pts[0].distance(pts[1]);
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0), 0.0, b));
}
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
for i in first..last {
let v = i % n;
let t = s / total;
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side));
let t = along[v] / total;
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 };
out.extend(ribs.into_iter().skip(skip));
s += pts[v].distance(pts[(v + 1) % n]);
}
if closed {
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side)));
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side)));
} else {
let (d, b) = (dir[seg_count - 1], lay.band(1.0, side));
out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b));
}
out
}
/// For each of the points of a subpath, how far the ribs of the points ahead of it reach
/// (`cover`), out to the first point more than `window` along (or the end of an open subpath,
/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last,
/// the first when `closed`). Both ends of the window only move forward, so a queue of the
/// points that reach farther than every point after them gives each maximum, and a subpath
/// costs time in proportion to its points however many a window spans.
pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec<f64> {
let n = cover.len().min(seg.len());
if n == 0 {
return vec![];
}
// A closed subpath's points ahead are numbered on round it once more.
let last = if closed { 2 * n - 1 } else { n - 1 };
let mut pos = Vec::with_capacity(last + 1);
let mut s = 0.0;
for k in 0..=last {
pos.push(s);
s += seg[k % n];
}
let mut out = Vec::with_capacity(n);
let mut queue: VecDeque<usize> = VecDeque::new();
let mut next = 1;
for i in 0..n {
next = next.max(i + 1);
let stop = if closed { i + n } else { last };
let mut past = next > i + 1 && pos[next - 1] - pos[i] > window;
while !past && next <= stop {
let r = cover[next % n];
while queue.back().is_some_and(|&k| cover[k % n] <= r) {
queue.pop_back();
}
queue.push_back(next);
past = pos[next] - pos[i] > window;
next += 1;
}
while queue.front().is_some_and(|&k| k <= i) {
queue.pop_front();
}
out.push(queue.front().map_or(0.0, |&k| cover[k % n]));
}
out
}
+110
View File
@@ -237,3 +237,113 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
}
}
}
/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of
/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece
/// runs the way the path does; across, square to it.
fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option<bool> {
slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| {
let d = s.to - s.from;
(d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along)
})
}
/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one
/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as
/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it.
#[test]
fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() {
// Right along y = 50, round, then down through the first leg at x = 50, near the narrow end.
let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false);
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let mut st = stroke(10.0, mode);
st.profile = Some(WidthProfile::taper_end());
let sl = slices(&crossing, &st);
// In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg,
// which is less than 1 pt either side of itself there.
let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode);
assert_eq!(level, Some(true), "{mode:?}: painted by the second leg");
// Where the two overlap, the second leg is on top.
let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode);
assert_eq!(level, Some(false), "{mode:?}: painted by the first leg");
assert_covered(&crossing, &st);
}
}
/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge.
#[test]
fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() {
// Right along y = 0, then back along y = 7: the way back is the narrow end of a taper.
let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false);
let total = 207.0;
let mut st = stroke(10.0, StrokeGradientMode::Along);
st.profile = Some(WidthProfile::taper_end());
let sl = slices(&hairpin, &st);
// Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the
// way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8.
let p = Point::new(50.0, 3.0);
let t = param(&sl, p).expect("in a slice");
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
assert_covered(&hairpin, &st);
}
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
/// 256 do.
#[test]
fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() {
// The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt.
let mut pts = vec![(0.0, 0.0), (100.0, 0.0)];
pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0)));
let hairpin = poly(&pts, false);
let total = 207.0;
let mut st = stroke(10.0, StrokeGradientMode::Along);
st.profile = Some(WidthProfile::taper_end());
st.cap = LineCap::Round;
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
let sl = slices(&hairpin, &st);
let p = Point::new(50.0, 3.0);
let t = param(&sl, p).expect("in a slice");
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
}
/// The widest the ribs ahead reach is the widest over the points out to the first one more than
/// the window along, as a walk along the subpath finds it, open and closed.
#[test]
fn widest_ahead_is_the_widest_within_the_window() {
let mut seed = 0x9e37_79b9_u64;
let mut rnd = move || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for _ in 0..200 {
let n = 1 + (rnd() * 40.0) as usize;
let cover: Vec<f64> = (0..n).map(|_| rnd() * 10.0).collect();
// Some points on top of each other.
let seg: Vec<f64> = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect();
let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize];
for closed in [false, true] {
let got = super::gradient::widest_ahead(&cover, &seg, closed, window);
assert_eq!(got.len(), n);
for (i, &got) in got.iter().enumerate() {
let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64);
for _ in 0..n {
let k = if closed {
(j + 1) % n
} else if j + 1 < n {
j + 1
} else {
break;
};
want = want.max(cover[k]);
d += seg[j];
j = k;
if d > window {
break;
}
}
assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}");
}
}
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Stroke gradients: where a variable-width stroke crosses or runs close by itself, a narrow part no longer paints over a wide part beside it, #5960; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged |
| 2026-11-07 | minor | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach, found in one pass along the path however densely it is flattened). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone`, `widest_ahead_is_the_widest_within_the_window`. Scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. |
| 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. |
@@ -596,6 +597,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html).
- **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode.
- **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`.
- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes).
- **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`.
- **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html).
- **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html).