mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
security(paint): cap spaced steps and procedural primitives (#8957)
* security(paint): cap distance-spaced steps per segment and procedural tip primitives * test(paint): count steps through a Cell so the walker closure can be shared * paint: keep the spacing carry-over when a segment ends at the step cap; lower the tip work budget
This commit is contained in:
@@ -26,6 +26,9 @@ const MAX_GRID_STEPS: f64 = 1e7;
|
||||
const MIN_SPEED_STEP: f64 = 0.5;
|
||||
/// Maximum airbrush catch-up dabs for one input segment.
|
||||
const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512;
|
||||
/// Most steps one distance-spaced segment emits: two far-apart points at a quarter-pixel spacing
|
||||
/// would otherwise be billions of steps, all buffered before the first is drawn.
|
||||
const MAX_DISTANCE_STEPS_PER_SEGMENT: usize = 1 << 20;
|
||||
|
||||
/// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the
|
||||
/// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding
|
||||
@@ -336,12 +339,17 @@ impl PathWalker {
|
||||
match self.speed.filter(|_| dt > 0.0 && dt.is_finite()) {
|
||||
None => {
|
||||
if !self.grid.is_some_and(|g| self.grid_steps(&a, &p, g, out)) {
|
||||
while self.next_at <= len + 1e-9 {
|
||||
let mut emitted = 0usize;
|
||||
while self.next_at <= len + 1e-9 && emitted < MAX_DISTANCE_STEPS_PER_SEGMENT {
|
||||
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
|
||||
self.emit(q, out);
|
||||
self.next_at += step_len(&q).max(0.25);
|
||||
emitted += 1;
|
||||
}
|
||||
self.next_at -= len;
|
||||
// Cut short: the rest of the segment is skipped, spacing restarts at its end.
|
||||
// Only when the cap held back a step that was still due; a segment that ends right at the cap keeps its carry-over.
|
||||
let cut_short = emitted == MAX_DISTANCE_STEPS_PER_SEGMENT && self.next_at <= len + 1e-9;
|
||||
self.next_at = if cut_short { step_len(&p).max(0.25) } else { self.next_at - len };
|
||||
}
|
||||
}
|
||||
Some(iv) => {
|
||||
@@ -815,3 +823,38 @@ impl DabGenerator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod walker_budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_far_segment_emits_a_bounded_number_of_steps() {
|
||||
let steps = std::cell::Cell::new(0usize);
|
||||
let mut count = |_: StepInput| steps.set(steps.get() + 1);
|
||||
let mut walker = PathWalker::new(None);
|
||||
walker.push(StrokePoint::new(0.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count);
|
||||
// At a quarter pixel, a billion-pixel segment is four billion steps.
|
||||
walker.push(StrokePoint::new(1.0e9, 0.0, 1.0), &|_: &StrokePoint| 0.25, &mut count);
|
||||
assert!(steps.get() <= MAX_DISTANCE_STEPS_PER_SEGMENT + 1, "{}", steps.get());
|
||||
// Spacing carries on from the end of the cut segment.
|
||||
let before = steps.get();
|
||||
walker.push(StrokePoint::new(1.0e9 + 10.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count);
|
||||
assert!(steps.get() > before && steps.get() - before <= 11, "{} more steps", steps.get() - before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_ending_at_the_cap_keeps_its_carry_over() {
|
||||
let steps = std::cell::Cell::new(0usize);
|
||||
let mut count = |_: StepInput| steps.set(steps.get() + 1);
|
||||
let mut walker = PathWalker::new(None);
|
||||
let one = |_: &StrokePoint| 1.0;
|
||||
walker.push(StrokePoint::new(0.0, 0.0, 1.0), &one, &mut count);
|
||||
// Exactly the cap in steps, then 0.75 left before the next is due.
|
||||
let end = MAX_DISTANCE_STEPS_PER_SEGMENT as f64 + 0.25;
|
||||
walker.push(StrokePoint::new(end, 0.0, 1.0), &one, &mut count);
|
||||
let before = steps.get();
|
||||
walker.push(StrokePoint::new(end + 0.8, 0.0, 1.0), &one, &mut count);
|
||||
assert_eq!(steps.get(), before + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +87,20 @@ pub fn chalk_tip(n: u32, seed: u32) -> GrayTile {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most primitives one generated tip is drawn from, and most (pixel × primitive) visits it costs:
|
||||
/// every pixel is tested against every primitive, so both counts are the caller's to multiply.
|
||||
const MAX_PRIMITIVES: u32 = 4096;
|
||||
const MAX_TIP_WORK: u64 = 1 << 27;
|
||||
|
||||
/// `count` limited so that an `n`×`n` tip tests at most [`MAX_TIP_WORK`] pixel/primitive pairs.
|
||||
fn clamp_primitives(n: u32, count: u32) -> u32 {
|
||||
let per_primitive = (u64::from(n) * u64::from(n)).max(1);
|
||||
count.min(MAX_PRIMITIVES).min(u32::try_from(MAX_TIP_WORK / per_primitive).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
/// A spatter tip: a cluster of round droplets of different sizes.
|
||||
pub fn spatter_tip(n: u32, seed: u32, drops: u32) -> GrayTile {
|
||||
let drops = clamp_primitives(n, drops);
|
||||
let salt = u64::from(seed) + 202;
|
||||
let s = n as f32;
|
||||
let blobs: Vec<(f32, f32, f32)> = (0..drops)
|
||||
@@ -162,6 +174,7 @@ pub fn leaf_tip(n: u32) -> GrayTile {
|
||||
|
||||
/// Grass: a tuft of tapered, slightly curved blades rising from the bottom edge.
|
||||
pub fn grass_tip(n: u32, seed: u32, blades: u32) -> GrayTile {
|
||||
let blades = clamp_primitives(n, blades);
|
||||
let salt = u64::from(seed) + 505;
|
||||
let s = n as f32;
|
||||
let bl: Vec<(f32, f32, f32, f32)> = (0..blades)
|
||||
@@ -217,7 +230,7 @@ pub fn star_tip(n: u32) -> GrayTile {
|
||||
pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
|
||||
let salt = u64::from(seed) + 707;
|
||||
let s = n as f32;
|
||||
let k = bristles.max(1);
|
||||
let k = clamp_primitives(n, bristles).max(1);
|
||||
let dots: Vec<(f32, f32, f32)> = (0..k)
|
||||
.map(|i| {
|
||||
let x = s * (i as f32 + 0.5) / k as f32;
|
||||
@@ -239,6 +252,19 @@ pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tips_with_absurd_primitive_counts_stay_cheap() {
|
||||
// 4 billion drops over a 1024² tip would be 4·10¹⁵ pixel tests.
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(spatter_tip(1024, 1, u32::MAX).width, 1024);
|
||||
assert_eq!(grass_tip(1024, 1, u32::MAX).height, 1024);
|
||||
assert_eq!(rake_tip(1024, 1, u32::MAX).width, 1024);
|
||||
assert!(t.elapsed().as_secs() < 60, "{:?}", t.elapsed());
|
||||
assert!(clamp_primitives(1024, u32::MAX) <= MAX_PRIMITIVES);
|
||||
assert!(clamp_primitives(60_000, 100) < 100, "a huge tip gets fewer primitives");
|
||||
assert_eq!(clamp_primitives(0, 7), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_are_seamless_and_in_range() {
|
||||
for st in [PatternStyle::Noise, PatternStyle::Paper, PatternStyle::Canvas, PatternStyle::Dots] {
|
||||
|
||||
Reference in New Issue
Block a user