From e6819a86f0132bbc5dae172862e96ebb270cde3a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 21:28:23 -0800 Subject: [PATCH] Rendering: open width-profile strokes outline only near the view --- crates/effects/src/stroke.rs | 11 ++++- crates/effects/src/stroke/width.rs | 66 ++++++++++++++++++++++++++- crates/render/src/renderer/paint.rs | 20 ++++++-- crates/render/src/tests/longstroke.rs | 43 ++++++++++++++++- docs/gaps.md | 8 ++++ 5 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 2ae452ed5..6dfcacce2 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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 { + 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. diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 324fddfac..6d0a282e8 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -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 { + 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. diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index ae65cf845..fd205496a 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -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() diff --git a/crates/render/src/tests/longstroke.rs b/crates/render/src/tests/longstroke.rs index ffabf0a26..8a6567f39 100644 --- a/crates/render/src/tests/longstroke.rs +++ b/crates/render/src/tests/longstroke.rs @@ -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(); diff --git a/docs/gaps.md b/docs/gaps.md index 75bd9c8b4..adb50d60f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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