mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606)
Liquify dabs were spaced a fifth of the brush radius apart and restarted at every pointer event, so a fast mouse (few far-apart events) and a slow one (many close ones) warped the same path differently, and a slow drag piled up one dab per event. Dabs now fall every twentieth of the radius by distance travelled along the whole stroke, carried from one segment to the next, as in Photoshop: any sampling of a path gives the same field. The stationary tools' per-dab strength is scaled to the new spacing so their effect per distance travelled is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
62f4b9d453
commit
44dc6f002d
2 files changed
+212
-56
No files matched your search
@@ -59,6 +59,22 @@ fn main() {
|
||||
prev = p;
|
||||
}
|
||||
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
|
||||
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
|
||||
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
|
||||
// stroke, and how far the two results differ.
|
||||
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
|
||||
let mut fields = Vec::new();
|
||||
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
|
||||
for step in [2.0, 60.0] {
|
||||
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
|
||||
let t = Instant::now();
|
||||
let mut f = LiquifyField::new(b, cell);
|
||||
f.apply_stroke(&drag);
|
||||
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
fields.push(f);
|
||||
}
|
||||
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
|
||||
st.tool = LiquifyTool::Bloat;
|
||||
let t = Instant::now();
|
||||
let mut d = Rect::EMPTY;
|
||||
|
||||
+196
-56
@@ -154,7 +154,7 @@ impl LiquifyStroke {
|
||||
}
|
||||
|
||||
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiquifyField {
|
||||
pub bounds: Rect,
|
||||
/// Document pixels per field node.
|
||||
@@ -165,6 +165,38 @@ pub struct LiquifyField {
|
||||
pub d: Vec<[f32; 2]>,
|
||||
/// Freeze mask per node (0 = free, 1 = frozen).
|
||||
pub freeze: Vec<f32>,
|
||||
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
|
||||
cursor: Option<StrokeCursor>,
|
||||
}
|
||||
|
||||
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
|
||||
/// transient brush state, not part of the result.
|
||||
impl PartialEq for LiquifyField {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
|
||||
/// the brush has travelled along the path since that dab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct StrokeCursor {
|
||||
end: [f64; 2],
|
||||
last_dab: [f64; 2],
|
||||
carry: f64,
|
||||
}
|
||||
|
||||
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
|
||||
/// drag is many small pushes and its edge stays smooth.
|
||||
fn dab_spacing(r: f64) -> f64 {
|
||||
(r * 0.05).max(0.5)
|
||||
}
|
||||
|
||||
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
|
||||
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
|
||||
/// was with dabs a fifth of the radius apart.
|
||||
fn moving_weight(r: f64) -> f64 {
|
||||
dab_spacing(r) / (r * 0.2).max(0.5)
|
||||
}
|
||||
|
||||
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
|
||||
@@ -177,7 +209,7 @@ impl LiquifyField {
|
||||
let cell = cell.clamp(1.0, 64.0);
|
||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||
}
|
||||
|
||||
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
|
||||
@@ -220,6 +252,7 @@ impl LiquifyField {
|
||||
|
||||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
self.cursor = None;
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
@@ -306,77 +339,120 @@ impl LiquifyField {
|
||||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
|
||||
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
|
||||
}
|
||||
|
||||
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
|
||||
/// tool held still dabs once at `b`).
|
||||
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
|
||||
/// along the whole stroke, carried from one segment to the next, so the result is the same
|
||||
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
|
||||
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
|
||||
/// ended, the stroke starts afresh at `a`.
|
||||
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
self.walk_segment(s, a, b, true)
|
||||
}
|
||||
|
||||
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
|
||||
/// would change nothing), which tests compare with the full walk.
|
||||
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let stationary = s.tool.is_stationary();
|
||||
let cur = match self.cursor {
|
||||
Some(c) if c.end == [a[0], a[1]] => c,
|
||||
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
|
||||
};
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = dx.hypot(dy);
|
||||
if len == 0.0 {
|
||||
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
|
||||
self.cursor = Some(cur);
|
||||
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
|
||||
}
|
||||
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
|
||||
if !len.is_finite() {
|
||||
self.cursor = None;
|
||||
return dirty;
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let spacing = (r * 0.2).max(0.5);
|
||||
let steps = (len / spacing).ceil().max(1.0) as usize;
|
||||
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
|
||||
// touching anything, and a segment to a far-off point has trillions of them (#937).
|
||||
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
|
||||
for k in k0..=k1 {
|
||||
let t0 = (k - 1) as f64 / steps as f64;
|
||||
let t1 = k as f64 / steps as f64;
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let pr = pa + (pb - pa) * t1;
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pr));
|
||||
let spacing = dab_spacing(r);
|
||||
let weight = if stationary { moving_weight(r) } else { 1.0 };
|
||||
// Distance along this segment of its first dab, then one every `spacing`.
|
||||
let first = (spacing - cur.carry).clamp(0.0, spacing);
|
||||
// Distances are compared with a little slack: a dab that rounding puts a hair past the
|
||||
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
|
||||
let slack = spacing * 1e-6;
|
||||
if first > len + slack {
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
|
||||
return dirty;
|
||||
}
|
||||
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
|
||||
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
|
||||
let (mut k0, mut k1) = (0.0f64, last_k);
|
||||
if skip {
|
||||
match self.reachable_span(a, [dx, dy], len, r) {
|
||||
// A dragging dab is centred one spacing behind its position: pad by that and a step.
|
||||
Some((d0, d1)) => {
|
||||
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
|
||||
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
|
||||
}
|
||||
None => k1 = -1.0,
|
||||
}
|
||||
}
|
||||
if k0 <= k1 {
|
||||
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
|
||||
for k in (k0 as u64)..=(k1 as u64) {
|
||||
let dist = (first + k as f64 * spacing).min(len);
|
||||
let c = at(dist);
|
||||
let delta = [c[0] - prev[0], c[1] - prev[1]];
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if stationary { c } else { prev };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
let last = (first + last_k * spacing).min(len);
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
|
||||
dirty
|
||||
}
|
||||
|
||||
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
|
||||
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
|
||||
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
|
||||
/// node only when its centre is within `r` plus a node of the node grid, so the window below
|
||||
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
|
||||
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
|
||||
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
|
||||
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
|
||||
/// near that span can touch a node; a segment to a far-off point has trillions of others
|
||||
/// (#937).
|
||||
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return None;
|
||||
}
|
||||
let pad = r + 2.0 * self.cell;
|
||||
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
|
||||
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
|
||||
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
|
||||
let (mut k0, mut k1) = (1.0f64, n);
|
||||
let (mut u0, mut u1) = (0.0f64, 1.0f64);
|
||||
for ax in 0..2 {
|
||||
let d = b[ax] - a[ax];
|
||||
let d = dir[ax];
|
||||
if d == 0.0 {
|
||||
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
|
||||
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
|
||||
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
u0 = u0.max(v0.min(v1));
|
||||
u1 = u1.min(v0.max(v1));
|
||||
}
|
||||
// NaN: a segment whose length overflowed.
|
||||
if k0.is_nan() || k1.is_nan() || k0 > k1 {
|
||||
if u0.is_nan() || u1.is_nan() || u0 > u1 {
|
||||
return None;
|
||||
}
|
||||
Some((k0 as usize, k1 as usize))
|
||||
Some((u0 * len, u1 * len))
|
||||
}
|
||||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
|
||||
/// document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
let density = (s.density / 100.0).clamp(0.0, 1.0);
|
||||
if strength <= 0.0 {
|
||||
@@ -921,21 +997,9 @@ mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
/// The segment walk before #937: every step, whether or not its dab can reach the field.
|
||||
/// The segment walk before #937: every dab, whether or not it can reach the field.
|
||||
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
|
||||
for k in 1..=steps {
|
||||
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
|
||||
}
|
||||
dirty
|
||||
f.walk_segment(s, a, b, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -945,7 +1009,7 @@ mod tests {
|
||||
// every result bit-identical to the full walk.
|
||||
use LiquifyTool::*;
|
||||
let bounds = Rect::new(-7, 3, 89, 67);
|
||||
let segments: [([f64; 3], [f64; 3]); 9] = [
|
||||
let segments: [([f64; 3], [f64; 3]); 10] = [
|
||||
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
|
||||
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
|
||||
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
|
||||
@@ -955,6 +1019,7 @@ mod tests {
|
||||
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
|
||||
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
|
||||
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
|
||||
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
|
||||
];
|
||||
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
|
||||
for size in [6.0, 40.0] {
|
||||
@@ -973,6 +1038,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
|
||||
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
|
||||
let mut out = vec![path[0].to_vec()];
|
||||
for w in path.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
// Uneven steps, as a real pointer gives: t grows quadratically.
|
||||
for k in 1..=parts {
|
||||
let t = (k * k) as f64 / (parts * parts) as f64;
|
||||
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
|
||||
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
|
||||
/// whole stroke: any sampling of a path gives the same field.
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
|
||||
use LiquifyTool::*;
|
||||
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
|
||||
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
|
||||
for size in [12.0, 40.0] {
|
||||
let field = |parts: usize| {
|
||||
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
|
||||
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
|
||||
let mut s = LiquifyStroke::new(tool, size);
|
||||
s.points = sampled(&path, parts);
|
||||
// Some displacement first, so Reconstruct and Smooth have something to change.
|
||||
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
|
||||
};
|
||||
let coarse = field(1);
|
||||
for parts in [3, 17, 60] {
|
||||
let fine = field(parts);
|
||||
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
|
||||
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
|
||||
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
|
||||
/// up differs by a whole dab between a slow and a fast pointer.
|
||||
#[test]
|
||||
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
|
||||
let field = |step: f64| {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
|
||||
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
|
||||
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
|
||||
};
|
||||
let (slow, fast) = (field(2.0), field(60.0));
|
||||
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
|
||||
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
||||
// A segment that does not start where the last one ended begins a new run of dabs there.
|
||||
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
|
||||
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
|
||||
let mut g = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
|
||||
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
assert_eq!(f, g);
|
||||
// Hostile points: nothing panics and the next segment starts afresh.
|
||||
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
|
||||
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_to_a_far_off_point_returns() {
|
||||
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
|
||||
@@ -1110,8 +1250,8 @@ mod tests {
|
||||
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
|
||||
#[test]
|
||||
fn forward_warp_leaves_the_brush_rim_in_place() {
|
||||
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 4.0);
|
||||
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 1.0);
|
||||
for density in [0.0, 50.0, 100.0] {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
|
||||
s.density = density;
|
||||
|
||||
Reference in new issue
Block a user