Files
photocraft/crates/vector/src/flatten.rs
T
Valentin KleinandClaude Opus 5.5 bcaadccbfa Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204)
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:02:09 -07:00

228 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Bézier flattening with a distance tolerance.
use photocraft_doc::{Path, Subpath};
use photocraft_geom::Point;
/// A flattened subpath.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Polyline {
pub pts: Vec<(f64, f64)>,
/// `true` for points that are knots of the source path (joins apply there); `false` for
/// points inserted while flattening a curve.
pub knot: Vec<bool>,
pub closed: bool,
}
impl Polyline {
fn push(&mut self, p: (f64, f64), knot: bool) {
if let Some(&last) = self.pts.last()
&& (last.0 - p.0).abs() < 1e-12
&& (last.1 - p.1).abs() < 1e-12
{
if knot && let Some(k) = self.knot.last_mut() {
*k = true;
}
return;
}
self.pts.push(p);
self.knot.push(knot);
}
}
/// Straight segment: both control points lie on the chord (within 1e-9 px) between its ends.
fn is_line(seg: &[Point; 4]) -> bool {
let [p0, c1, c2, p1] = seg;
let (dx, dy) = (p1.x - p0.x, p1.y - p0.y);
let len2 = dx * dx + dy * dy;
let on_chord = |c: &Point| {
let (vx, vy) = (c.x - p0.x, c.y - p0.y);
if len2 < 1e-24 {
return vx.abs() < 1e-9 && vy.abs() < 1e-9;
}
let t = (vx * dx + vy * dy) / len2;
let cross = (vx * dy - vy * dx).abs() / len2.sqrt();
cross < 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&t)
};
on_chord(c1) && on_chord(c2)
}
/// Number of uniform pieces so that the chord deviation of the cubic is at most `tol`
/// (bound `max|B''| / 8n²` with `max|B''| <= 6·max(|p0−2c1+c2|, |c1−2c2+p1|)`).
fn pieces(seg: &[Point; 4], tol: f64) -> usize {
let [p0, c1, c2, p1] = seg;
let d1 = ((p0.x - 2.0 * c1.x + c2.x).powi(2) + (p0.y - 2.0 * c1.y + c2.y).powi(2)).sqrt();
let d2 = ((c1.x - 2.0 * c2.x + p1.x).powi(2) + (c1.y - 2.0 * c2.y + p1.y).powi(2)).sqrt();
let m = d1.max(d2);
// Small curves get a proportionally finer tolerance so their area stays accurate.
let size = (c1.x - p0.x).hypot(c1.y - p0.y) + (c2.x - c1.x).hypot(c2.y - c1.y) + (p1.x - c2.x).hypot(p1.y - c2.y);
let tol = tol.min(size * 1e-3).max(1e-4);
let n = (0.75 * m / tol).sqrt().ceil();
if n.is_finite() { (n as usize).clamp(1, 1 << 16) } else { 1 }
}
fn eval(seg: &[Point; 4], t: f64) -> (f64, f64) {
let [p0, c1, c2, p1] = seg;
let mt = 1.0 - t;
let a = mt * mt * mt;
let b = 3.0 * mt * mt * t;
let c = 3.0 * mt * t * t;
let d = t * t * t;
(a * p0.x + b * c1.x + c * c2.x + d * p1.x, a * p0.y + b * c1.y + c * c2.y + d * p1.y)
}
/// Validate before interactive rendering: finite coordinates and a bounded tessellation and
/// dash workload. Arc length is bounded by the cubic control polygon, without allocating pixels.
pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) -> Result<(), &'static str> {
let knots = path.subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.knots.len())).ok_or("too many path knots")?;
if knots > 4096 || path.subpaths.len() > 4096 {
return Err("shape path exceeds 4096 knots/subpaths");
}
let mut points = 0usize;
let mut length = 0.0;
for sub in &path.subpaths {
for k in &sub.knots {
for p in [k.anchor, k.in_ctrl, k.out_ctrl] {
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1_000_000.0 || p.y.abs() > 1_000_000.0 {
return Err("shape coordinates must be finite and within ±1000000 px");
}
}
}
points = points.saturating_add(1);
for seg in sub.segments() {
points = points.saturating_add(if is_line(&seg) { 1 } else { pieces(&seg, crate::DEFAULT_TOLERANCE) });
length += seg.windows(2).map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)).sum::<f64>();
}
}
if points > 100_000 {
return Err("shape tessellation exceeds 100000 points");
}
if let Some(s) = stroke {
s.validate()?;
if s.width > 0.0 && !s.dashes.is_empty() {
let period = s.dashes.iter().map(|d| f64::from(*d)).sum::<f64>() * f64::from(s.width);
let transitions = (length / period + 2.0) * s.dashes.len() as f64 + points as f64;
if !transitions.is_finite() || transitions > 100_000.0 {
return Err("dash pattern is too dense for this path (100000 step budget)");
}
}
}
Ok(())
}
/// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks
/// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile
/// curves cannot exhaust memory.
pub(crate) const MAX_PATH_POINTS: usize = 1 << 20;
/// How much to coarsen the curves of `subpaths` at `tol` so the whole path stays within
/// [`MAX_PATH_POINTS`]: the share of each curve's pieces to keep (1 = all of them).
fn piece_scale<'a>(subpaths: impl IntoIterator<Item = &'a Subpath>, tol: f64) -> f64 {
// Subpath starts and straight segments take one point each and cannot be coarsened.
let (mut fixed, mut curves) = (0usize, 0usize);
for s in subpaths {
fixed = fixed.saturating_add(1);
for seg in s.segments() {
if is_line(&seg) {
fixed = fixed.saturating_add(1);
} else {
curves = curves.saturating_add(pieces(&seg, tol));
}
}
}
let total = fixed.saturating_add(curves);
if total <= MAX_PATH_POINTS { 1.0 } else { MAX_PATH_POINTS as f64 / total as f64 }
}
/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. A hostile
/// subpath is flattened more coarsely so it stays within a bounded number of points.
pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline {
flatten_scaled(s, tol, piece_scale([s], tol))
}
/// [`flatten_subpath`] keeping the share `scale` of each curve's pieces (at least one).
fn flatten_scaled(s: &Subpath, tol: f64, scale: f64) -> Polyline {
let mut out = Polyline { closed: s.closed, ..Default::default() };
if let Some(k) = s.knots.first() {
out.push((k.anchor.x, k.anchor.y), true);
}
for seg in &s.segments() {
if is_line(seg) {
out.push((seg[3].x, seg[3].y), true);
continue;
}
let n = pieces(seg, tol);
let n = if scale < 1.0 { ((n as f64 * scale) as usize).max(1) } else { n };
for j in 1..=n {
out.push(eval(seg, j as f64 / n as f64), j == n);
}
}
if s.closed && out.pts.len() > 1 {
let (a, b) = (out.pts[0], out.pts[out.pts.len() - 1]);
if (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12 {
out.pts.pop();
out.knot.pop();
out.knot[0] = true;
}
}
out
}
/// Flattens every subpath (see [`flatten_subpath`]); a hostile path is flattened more coarsely
/// so the whole path stays within a bounded number of points.
pub fn flatten_path(p: &Path, tol: f64) -> Vec<Polyline> {
let scale = piece_scale(&p.subpaths, tol);
p.subpaths.iter().map(|s| flatten_scaled(s, tol, scale)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes;
use photocraft_doc::Knot;
#[test]
fn lines_are_not_subdivided() {
let s = Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
let p = flatten_subpath(&s, 0.1);
assert_eq!(p.pts.len(), 3);
assert!(p.knot.iter().all(|k| *k));
}
#[test]
fn circle_flattening_respects_tolerance() {
for tol in [1.0, 0.25, 0.01] {
let path = shapes::ellipse(0.0, 0.0, 200.0, 200.0);
let p = flatten_subpath(&path.subpaths[0], tol);
// Kappa circles deviate from the true circle by ~0.027% of r; chords add ≤ tol.
for w in p.pts.windows(2) {
let m = ((w[0].0 + w[1].0) / 2.0 - 100.0, (w[0].1 + w[1].1) / 2.0 - 100.0);
let r = (m.0 * m.0 + m.1 * m.1).sqrt();
assert!(100.0 - r <= tol + 0.03, "tol {tol}: chord mid at r={r}");
}
assert!(p.pts.len() < 4 * (1 + (0.75 * 100.0 / tol.min(0.15)).sqrt() as usize + 1));
}
}
#[test]
fn hostile_curves_flatten_within_the_point_budget() {
// Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far
// outside the canvas: each of these segments alone asks for the 65536-piece cap, so the
// path would flatten to ~1.3M points (#5195).
let segments = 20;
let knots = (0..=segments)
.map(|i| {
let a = Point::new(f64::from(i) * 50.0, f64::from(i % 2) * 1000.0);
Knot::smooth(a, Point::new(a.x - 3e7, a.y + 3e7), Point::new(a.x + 3e7, a.y - 3e7))
})
.collect();
let path = Path::new(vec![Subpath { closed: false, knots, op: Default::default() }]);
let lines = flatten_path(&path, 0.01);
let points: usize = lines.iter().map(|l| l.pts.len()).sum();
assert!(points > 1000 && points <= MAX_PATH_POINTS + segments as usize + 1, "{points} points");
assert_eq!(flatten_subpath(&path.subpaths[0], 0.01).pts.len(), points);
// Ordinary paths are flattened exactly as before.
let circle = shapes::ellipse(0.0, 0.0, 200.0, 200.0);
assert_eq!(flatten_path(&circle, 0.01)[0], flatten_scaled(&circle.subpaths[0], 0.01, 1.0));
}
}