Merge pull request #5883 from dotCooCoo/fix/profile-stroke-crop

Rendering: open width-profile strokes outline only near the view
This commit is contained in:
BFlat
2026-11-06 13:56:09 +08:00
committed by GitHub
5 changed files with 142 additions and 6 deletions
+10 -1
View File
@@ -19,7 +19,7 @@ mod width;
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape};
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Rect, Shape};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
@@ -120,6 +120,15 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
}
}
/// The variable-width outline near `near`, with its profile positions measured on the whole
/// undashed line. Returns `None` when no segment is omitted or the stroke is dashed.
pub fn line_outline_profile_near(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64, near: Rect) -> Option<BezPath> {
if st.dash.is_some() || line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
return None;
}
width::width_outline_near(line, width, st.profile.as_ref()?, st, tol, near)
}
/// [`line_outline`] of a dashed line without a width profile, made of only the dashes and dots on
/// the segments of `line` that `keep` accepts ([`dash_kept`]). `None` when `st` has no dash
/// pattern or has a width profile, or when its pattern draws the line solid.
+65 -1
View File
@@ -10,7 +10,7 @@
use std::f64::consts::PI;
use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2};
use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Rect, Vec2};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::{kink, segments, subpaths, unit};
@@ -21,6 +21,70 @@ pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &Stro
outline_spans(bp, &[], width, profile, st, tol)
}
/// Outline only flattened segments near `near`, retaining each run's original fractions of its
/// whole subpath for the width profile. `None` means no segments could be omitted.
pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64, near: Rect) -> Option<BezPath> {
let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) };
let mut out = BezPath::new();
let mut cut = false;
for (r, closed) in subpaths(bp) {
if closed {
return None;
}
let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol);
let n = pts.len();
if n < 2 {
continue;
}
let count = n - 1;
let mut lengths = Vec::with_capacity(count);
let mut kept = Vec::with_capacity(count);
let mut total = 0.0;
for i in 0..count {
let (a, b) = (pts[i], pts[i + 1]);
let len = a.distance(b);
if !len.is_finite() {
return None;
}
total += len;
lengths.push(len);
kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1));
}
if kept.iter().all(|v| *v) {
ctx.subpath(&mut out, &pts, &corner, closed, (0.0, 1.0));
continue;
}
cut = true;
if total <= 1e-12 {
continue;
}
let mut walked = 0.0;
let mut run = Vec::new();
let mut run_corner = Vec::new();
let mut from = 0.0;
for i in 0..count {
if kept[i] {
if run.is_empty() {
from = walked / total;
run.push(pts[i]);
run_corner.push(corner[i]);
}
run.push(pts[i + 1]);
run_corner.push(corner[i + 1]);
} else if run.len() > 1 {
ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total));
run.clear();
run_corner.clear();
}
walked += lengths[i];
}
if run.len() > 1 {
ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total));
}
}
cut.then_some(out)
}
/// [`width_outline`] of pieces of longer subpaths: subpath `i` of `bp` covers the fractions
/// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans));
/// subpaths without a span are whole.
+17 -3
View File
@@ -313,11 +313,25 @@ impl Renderer {
}
#[cfg(test)]
STROKES_OUTLINED.with(|n| n.set(n.get() + 1));
// A long stroke without a width profile is outlined only in and around the view, with a
// margin of the view's own size on each side ([`crop_dashed`] for a dashed one,
// [`crop_line`] otherwise).
// A long stroke is outlined only in and around the view, with a margin of the view's
// own size on each side. Width profiles keep their positions on the whole path.
let region = v.inflate(v.width(), v.height());
let tol = 2f64.powi(level);
if crops()
&& line.elements().len() > CROP_ELEMENTS
&& st.dash.is_none()
&& width >= tol * 4.0
&& let Some(profile) = &st.profile
&& profile.points.iter().all(|(t, l, r)| t.is_finite() && l.is_finite() && r.is_finite())
{
let side = profile.points.iter().flat_map(|&(_, l, r)| [l, r]).fold(1.0_f64, f64::max);
let reach = crop_reach(st, width * side, f.px, tol);
if let Some(outline) = effects::stroke::line_outline_profile_near(line, st, width, tol, region.inflate(reach, reach)) {
let outline = Arc::new(outline);
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop: Some(region), own }, moved);
return Some(outline);
}
}
if crops()
&& line.elements().len() > CROP_ELEMENTS
&& st.dash.is_some()
+42 -1
View File
@@ -2,7 +2,7 @@
//! and for dashes `dash_kept`), and draws the same pixels as when it is outlined whole.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, Node, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, Node, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, Vec2};
use crate::renderer::{CROP_STROKES, crop_line};
@@ -66,6 +66,47 @@ fn zigzag() -> BezPath {
bp
}
#[test]
fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() {
let mut lens = stroke(16.0, LineJoin::Miter, LineCap::Square);
lens.profile = WidthProfile::preset("lens");
let mut taper = stroke(12.0, LineJoin::Round, LineCap::Round);
taper.profile = WidthProfile::preset("taperEnd");
let mut asymmetric = stroke(12.0, LineJoin::Miter, LineCap::Square);
asymmetric.profile = Some(WidthProfile { points: vec![(0.0, 2.0, 0.1), (1.0, 0.1, 2.0)] });
let mut two = zigzag();
two.move_to(Point::new(0.0, 100.0));
for i in 1..3000 {
two.line_to(Point::new(i as f64 * 8.0, 100.0));
}
for (name, d, at, cropped) in [
("open zigzag, lens at middle", doc(&zigzag(), lens), Point::new(12_000.0, 15.0), true),
("open zigzag, taper at start", doc(&zigzag(), taper.clone()), Point::new(0.0, 15.0), true),
("open zigzag, taper at end", doc(&zigzag(), taper.clone()), Point::new(23_990.0, 15.0), true),
("open zigzag, asymmetric profile", doc(&zigzag(), asymmetric), Point::new(12_000.0, 15.0), true),
("two open subpaths", doc(&two, taper.clone()), Point::new(12_000.0, 60.0), true),
("closed ring at seam", doc(&ring(4000, 5000.0), taper.clone()), Point::new(5000.0, 0.0), false),
("closed ring away from seam", doc(&ring(4000, 5000.0), taper), Point::new(0.0, 5000.0), false),
] {
let mut renderer = Renderer::new();
let near = render(&mut renderer, &d, at);
assert_eq!(renderer.strokes.values().any(|e| e.crop.is_some()), cropped, "{name}: crop choice");
let full = whole(&d, at);
let mismatched: Vec<_> = near
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(full.pixels.as_chunks::<4>().0.iter())
.enumerate()
.filter(|(_, (a, b))| a != b)
.take(8)
.map(|(i, (a, b))| (i % SIZE as usize, i / SIZE as usize, a.to_vec(), b.to_vec()))
.collect();
assert!(mismatched.is_empty(), "{name}: pixels differ at {mismatched:?}");
}
}
/// A closed polygon of `n` anchors round a circle of radius `r` about the origin.
fn ring(n: usize, r: f64) -> BezPath {
let mut bp = BezPath::new();
+8
View File
@@ -36,6 +36,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. |
| 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. |
| 2026-11-05 | minor | G15: Selection skips building corner and pie widgets when the pointer is well outside a large selection; empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in local median runs. Other cursor targets remain active; estimate unchanged. |
| 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; after the path hit-test improvement, a 200,000-anchor path's segment cursor took 3.72 ms before and 2.66 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. |
| 2026-11-05 | minor | G15: large-selection transforms reuse object locations found during command validation; a 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs. Estimate unchanged. |
@@ -653,6 +655,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G15 Performance budgets and a GPU renderer
- **Improved (2026-11-05):** after an edit, a long open stroke with an undashed width profile
outlines the part near the view and keeps each point's original position along the profile.
A 200,000-segment outline took 59.95 ms whole and 8.44 ms cropped in local median runs; this
measures outline construction, not a complete frame. Closed and dashed profile strokes still
use their full outline. Pixel comparisons cover curved and straight open paths, their ends,
asymmetric profiles, two subpaths, and the closed-path fallback. The idle budget remains open.
- **Improved (2026-11-05):** when more than 128 objects are selected, Selection skips making
corner and pie widgets if the pointer is farther than their reach from the selected art.
Empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in