mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user