mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Merge pull request #4871 from Taha-Mahmoodi/fix/995-width-stroke-zoomed-out
Fix #995: a width-profile stroke keeps its shape when the view zooms out
This commit is contained in:
@@ -53,6 +53,15 @@ impl WidthProfile {
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
|
||||
/// right factors at any of the points (between them the profile is straight), 1 for a profile
|
||||
/// without points.
|
||||
pub fn widest(&self) -> f64 {
|
||||
if self.points.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
|
||||
}
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
|
||||
|
||||
@@ -109,8 +109,11 @@ impl Renderer {
|
||||
}
|
||||
ctx.set_transform(f.view * xf);
|
||||
let closed = effects::stroke::is_closed(bp);
|
||||
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9));
|
||||
// Keep hairlines visible when zoomed far out (at least ~1 device pixel). A width profile
|
||||
// multiplies the weight, so it is the profile's widest point that is kept visible: raising
|
||||
// the weight further would swell the whole stroke while the view zooms out (#995).
|
||||
let widest = st.profile.as_ref().map_or(1.0, |p| p.widest().max(1e-3));
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9) / widest);
|
||||
let inside = st.align == StrokeAlign::Inside && closed;
|
||||
if inside {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
|
||||
@@ -29,6 +29,7 @@ mod setup;
|
||||
mod strokegradient;
|
||||
mod tileedge;
|
||||
mod viewraster;
|
||||
mod widthzoom;
|
||||
|
||||
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
|
||||
let id = d.alloc_id();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! A stroke with a width profile keeps its shape when the view zooms out (#995): only a stroke too
|
||||
//! thin to see is thickened, and then by what its widest point needs, not all of it at once.
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const SIZE: u32 = 200;
|
||||
|
||||
/// A line across a 200×200 page, stroked black `weight` pt with `profile`, drawn at `scale`.
|
||||
fn render(weight: f64, profile: Option<WidthProfile>, scale: f64) -> Rendered {
|
||||
let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), weight);
|
||||
st.profile = profile;
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((20.0, 100.0));
|
||||
p.line_to((180.0, 100.0));
|
||||
let mut d = Document::new(SIZE as f64, SIZE as f64);
|
||||
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&d, SIZE, SIZE, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Device pixels of ink in the column through the middle of the line (coverage summed, so a
|
||||
/// stroke narrower than a pixel still counts what it covers).
|
||||
fn ink_at_middle(img: &Rendered, scale: f64) -> f64 {
|
||||
let x = (100.0 * scale) as u32;
|
||||
(0..SIZE).map(|y| 1.0 - img.pixel(x, y)[0] as f64 / 255.0).sum()
|
||||
}
|
||||
|
||||
/// Thin at both ends, six times the weight in the middle.
|
||||
fn swell() -> WidthProfile {
|
||||
WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 6.0, 6.0), (1.0, 1.0, 1.0)] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_width_stroke_keeps_its_thickness_in_document_terms_when_zoomed_out() {
|
||||
let whole = ink_at_middle(&render(1.0, Some(swell()), 1.0), 1.0);
|
||||
assert!((whole - 6.0).abs() < 0.3, "six points at full size: {whole}");
|
||||
for scale in [0.5, 0.25, 0.125] {
|
||||
let ink = ink_at_middle(&render(1.0, Some(swell()), scale), scale);
|
||||
let in_points = ink / scale;
|
||||
assert!((in_points - whole).abs() < whole * 0.05, "at {scale}: {in_points} pt against {whole} pt");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_too_thin_to_see_is_kept_visible_by_its_widest_point() {
|
||||
// 0.1 pt, twice that in the middle: at a quarter size even the middle is a twentieth of a
|
||||
// pixel, so it is thickened to the half pixel a plain hairline gets, and the profile's ends
|
||||
// stay half of it.
|
||||
let thin = WidthProfile { points: vec![(0.0, 0.5, 0.5), (0.5, 1.0, 1.0), (1.0, 0.5, 0.5)] };
|
||||
let plain = ink_at_middle(&render(0.1, None, 0.25), 0.25);
|
||||
let img = render(0.1, Some(thin), 0.25);
|
||||
let shaped = ink_at_middle(&img, 0.25);
|
||||
assert!((plain - 0.5).abs() < 0.1, "a plain hairline gets half a pixel: {plain}");
|
||||
assert!((shaped - 0.5).abs() < 0.1, "the widest point of a profile gets it too: {shaped}");
|
||||
// Seven pixels in, 28 pt along the page: just past the line's start at 20 pt.
|
||||
let end: f64 = (0..SIZE).map(|y| 1.0 - img.pixel(7, y)[0] as f64 / 255.0).sum();
|
||||
assert!(end < shaped * 0.75, "the thin end stays thinner than the middle: {end} against {shaped}");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a width-profile stroke keeps its shape when the view zooms out, #995) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. |
|
||||
| 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. |
|
||||
| 2026-10-31 | minor | Hardening: a gradient mesh read from a file is budgeted at about a million quads, so a huge one no longer exhausts memory when drawn or exported (#3592); scores unchanged. |
|
||||
| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. |
|
||||
@@ -507,6 +508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Width-point finite-arithmetic follow-up (G1/M3):** Adjoining adjustments avoid intermediate overflow; non-finite normalized or resulting factors are rejected before committing. Engine regressions in `tests/widthpoints.rs` cover finite extremes, small nonzero results, atomic rejection, Undo/Redo and native save/reopen. Historical focused validation on the `a3357121` integration observed 2 expected RED failures with 11 passing controls, then all 13 tests passing with the repair; none were ignored. All three added regressions passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. Scores and hours unchanged.
|
||||
- **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.
|
||||
- **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).
|
||||
- **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`).
|
||||
|
||||
Reference in New Issue
Block a user