mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
Render: at very large coordinates an edited path is not taken for a moved one
The check that reuses a moved path's stroke outline allowed each coordinate to differ from the moved one by 4 x f64::EPSILON times the sum of the compared coordinates and the largest coordinate of the first point, about 2.7 pt near 1e15, so a 0.5 pt anchor or handle edit there drew the outline made before the edit. offset_between and anchors_moved now measure the offset on each axis at the pair of points with the smallest coordinates on that axis, then check every pair against that offset within 3 units in the last place of the pair's own coordinates. A first anchor far from the origin no longer sets the tolerance for points near it. Tests: 0.5 pt anchor and handle edits at 1e9 and 1e15 (and 0.0005 pt at 1e9); an edit near the origin of a path that starts at 1e16; a path whose coordinates shrink with each point, moved by growing amounts.
This commit is contained in:
@@ -19,7 +19,7 @@ use super::{
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use paint::{STROKES_OUTLINED, offset_between};
|
||||
pub(crate) use paint::{STROKES_OUTLINED, anchors_moved, offset_between};
|
||||
|
||||
mod images;
|
||||
mod nodes;
|
||||
|
||||
@@ -384,71 +384,99 @@ thread_local! {
|
||||
pub(crate) static STROKES_OUTLINED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each coordinate
|
||||
/// within a few units in the last place of the moved one, the error that adding the offset and
|
||||
/// measuring it from the first points can make). `None` otherwise, and for an empty path.
|
||||
/// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each point within
|
||||
/// the rounding that [`Offset::holds`] allows). `None` otherwise, and for an empty path.
|
||||
pub(crate) fn offset_between(was: &BezPath, now: &BezPath) -> Option<kurbo::Vec2> {
|
||||
use kurbo::PathEl;
|
||||
let (was, now) = (was.elements(), now.elements());
|
||||
if was.len() != now.len() {
|
||||
if !matches!(was.elements().first(), Some(kurbo::PathEl::MoveTo(_))) {
|
||||
return None;
|
||||
}
|
||||
let (by, first) = match (was.first()?, now.first()?) {
|
||||
(PathEl::MoveTo(a), PathEl::MoveTo(b)) => first_offset(*a, *b)?,
|
||||
_ => return None,
|
||||
};
|
||||
let near = |a: &kurbo::Point, b: &kurbo::Point| moved_by(*a, *b, by, first);
|
||||
let moved = was.iter().zip(now).all(|pair| match pair {
|
||||
(PathEl::MoveTo(a), PathEl::MoveTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => near(a, b),
|
||||
(PathEl::QuadTo(a0, a1), PathEl::QuadTo(b0, b1)) => near(a0, b0) && near(a1, b1),
|
||||
(PathEl::CurveTo(a0, a1, a2), PathEl::CurveTo(b0, b1, b2)) => near(a0, b0) && near(a1, b1) && near(a2, b2),
|
||||
(PathEl::ClosePath, PathEl::ClosePath) => true,
|
||||
_ => false,
|
||||
});
|
||||
moved.then_some(by)
|
||||
let mut offset = Offset::new();
|
||||
(path_pairs(was, now, |a, b| offset.takes(a, b)) && path_pairs(was, now, |a, b| offset.holds(a, b))).then(|| offset.by())
|
||||
}
|
||||
|
||||
/// The offset from first point `a` to first point `b` and the largest size of their coordinates,
|
||||
/// which [`moved_by`] measures its tolerance with. `None` when the offset is not finite.
|
||||
fn first_offset(a: kurbo::Point, b: kurbo::Point) -> Option<(kurbo::Vec2, f64)> {
|
||||
let by = b - a;
|
||||
by.is_finite().then(|| (by, a.x.abs().max(b.x.abs()).max(a.y.abs()).max(b.y.abs())))
|
||||
/// Whether `was` and `now` have the same elements, and `f` holds for each pair of their points.
|
||||
/// Stops at the first pair for which `f` does not hold.
|
||||
fn path_pairs(was: &BezPath, now: &BezPath, mut f: impl FnMut(kurbo::Point, kurbo::Point) -> bool) -> bool {
|
||||
use kurbo::PathEl;
|
||||
let (was, now) = (was.elements(), now.elements());
|
||||
was.len() == now.len()
|
||||
&& was.iter().zip(now).all(|pair| match pair {
|
||||
(PathEl::MoveTo(a), PathEl::MoveTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => f(*a, *b),
|
||||
(PathEl::QuadTo(a0, a1), PathEl::QuadTo(b0, b1)) => f(*a0, *b0) && f(*a1, *b1),
|
||||
(PathEl::CurveTo(a0, a1, a2), PathEl::CurveTo(b0, b1, b2)) => f(*a0, *b0) && f(*a1, *b1) && f(*a2, *b2),
|
||||
(PathEl::ClosePath, PathEl::ClosePath) => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `b` is `a` moved by `by`, each coordinate within a few units in the last place (see
|
||||
/// [`offset_between`]; `first` from [`first_offset`]).
|
||||
fn moved_by(a: kurbo::Point, b: kurbo::Point, by: kurbo::Vec2, first: f64) -> bool {
|
||||
let close = |a: f64, by: f64, b: f64| (a + by - b).abs() <= 4.0 * f64::EPSILON * (a.abs() + b.abs() + first);
|
||||
close(a.x, by.x, b.x) && close(a.y, by.y, b.y)
|
||||
/// One offset between pairs of points. Pairs are read once with [`Offset::takes`], which measures
|
||||
/// the offset on each axis at the pair with the smallest coordinates on that axis, and once more
|
||||
/// with [`Offset::holds`], which checks every pair against that offset.
|
||||
struct Offset {
|
||||
by: [f64; 2],
|
||||
size: [f64; 2],
|
||||
}
|
||||
|
||||
impl Offset {
|
||||
fn new() -> Self {
|
||||
Self { by: [0.0; 2], size: [f64::INFINITY; 2] }
|
||||
}
|
||||
|
||||
/// Whether `b` is `a` moved by the offset measured so far, on each axis within 3 units in the
|
||||
/// last place of the larger of the pair's coordinates and those of the pair the offset was
|
||||
/// measured at. A pair with smaller coordinates than any before it on an axis then measures the
|
||||
/// offset on that axis.
|
||||
fn takes(&mut self, a: kurbo::Point, b: kurbo::Point) -> bool {
|
||||
[(a.x, b.x), (a.y, b.y)].into_iter().zip(self.by.iter_mut().zip(&mut self.size)).all(|((a, b), (by, size))| {
|
||||
let at = a.abs().max(b.abs());
|
||||
if at.is_nan() || at >= *size {
|
||||
return (b - a - *by).abs() <= 3.0 * ulp(at);
|
||||
}
|
||||
let near = size.is_infinite() || (b - a - *by).abs() <= 3.0 * ulp(*size);
|
||||
(*by, *size) = (b - a, at);
|
||||
near && by.is_finite()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `b` is `a` moved by the offset, on each axis within 3 units in the last place of the
|
||||
/// larger of the pair's coordinates: the most that rounding `b`, the pair the offset was measured
|
||||
/// at (whose coordinates are no larger), and the two offsets can make together.
|
||||
fn holds(&self, a: kurbo::Point, b: kurbo::Point) -> bool {
|
||||
let near = |a: f64, b: f64, by: f64| (b - a - by).abs() <= 3.0 * ulp(a.abs().max(b.abs()));
|
||||
near(a.x, b.x, self.by[0]) && near(a.y, b.y, self.by[1])
|
||||
}
|
||||
|
||||
fn by(&self) -> kurbo::Vec2 {
|
||||
kurbo::Vec2::new(self.by[0], self.by[1])
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance from `x`, which is 0 or more, to the next larger `f64`.
|
||||
fn ulp(x: f64) -> f64 {
|
||||
f64::from_bits(x.to_bits() + 1) - x
|
||||
}
|
||||
|
||||
/// Whether the anchors and handles of path `now` are those of path `was` moved by one offset,
|
||||
/// measured as [`offset_between`] measures it, with the same subpaths, anchor counts and closed
|
||||
/// subpaths. It reads the anchors without making either path ([`Node::stroke_path`]), and stops at
|
||||
/// the first anchor that differs. `false` for objects other than paths.
|
||||
fn anchors_moved(was: &Node, now: &Node) -> bool {
|
||||
pub(crate) fn anchors_moved(was: &Node, now: &Node) -> bool {
|
||||
let (vectorcraft_doc::NodeKind::Path { path: was, .. }, vectorcraft_doc::NodeKind::Path { path: now, .. }) = (&was.kind, &now.kind) else {
|
||||
return false;
|
||||
};
|
||||
if was.subpaths.len() != now.subpaths.len() {
|
||||
return false;
|
||||
}
|
||||
let mut offset = None;
|
||||
for (a, b) in was.subpaths.iter().zip(&now.subpaths) {
|
||||
if a.closed != b.closed || a.anchors.len() != b.anchors.len() {
|
||||
return false;
|
||||
}
|
||||
for (a, b) in a.anchors.iter().zip(&b.anchors) {
|
||||
if offset.is_none() {
|
||||
offset = first_offset(a.p, b.p);
|
||||
}
|
||||
let Some((by, first)) = offset else { return false };
|
||||
if !(moved_by(a.p, b.p, by, first) && moved_by(a.h_in, b.h_in, by, first) && moved_by(a.h_out, b.h_out, by, first)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
let mut offset = Offset::new();
|
||||
anchor_pairs(was, now, |a, b| offset.takes(a, b)) && anchor_pairs(was, now, |a, b| offset.holds(a, b))
|
||||
}
|
||||
|
||||
/// Whether `was` and `now` have the same subpaths, anchor counts and closed subpaths, and `f` holds
|
||||
/// for each pair of their anchors and handles. Stops at the first pair for which `f` does not hold.
|
||||
fn anchor_pairs(was: &vectorcraft_geom::PathData, now: &vectorcraft_geom::PathData, mut f: impl FnMut(kurbo::Point, kurbo::Point) -> bool) -> bool {
|
||||
was.subpaths.len() == now.subpaths.len()
|
||||
&& was.subpaths.iter().zip(&now.subpaths).all(|(a, b)| {
|
||||
a.closed == b.closed
|
||||
&& a.anchors.len() == b.anchors.len()
|
||||
&& a.anchors.iter().zip(&b.anchors).all(|(a, b)| f(a.p, b.p) && f(a.h_in, b.h_in) && f(a.h_out, b.h_out))
|
||||
})
|
||||
}
|
||||
|
||||
fn crops() -> bool {
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_doc::{
|
||||
};
|
||||
use vectorcraft_geom::{PathData, Point, Vec2};
|
||||
|
||||
use crate::renderer::{STROKES_OUTLINED, offset_between};
|
||||
use crate::renderer::{STROKES_OUTLINED, anchors_moved, offset_between};
|
||||
use crate::*;
|
||||
|
||||
const SIZE: u32 = 200;
|
||||
@@ -246,16 +246,84 @@ fn another_width_profile_outlines_the_stroke_again() {
|
||||
|
||||
#[test]
|
||||
fn a_small_edit_far_from_the_origin_is_not_taken_for_a_move() {
|
||||
let far = Affine::translate((1e9, -1e9));
|
||||
let was = far * zigzag(20);
|
||||
for (far, edits) in [(1e9, &[0.5, 0.0005][..]), (1e15, &[0.5][..])] {
|
||||
let was = Affine::translate((far, -far)) * zigzag(20);
|
||||
let by = Vec2::new(0.25, 3.0);
|
||||
let moved = Affine::translate(by) * was.clone();
|
||||
assert_eq!(
|
||||
offset_between(&was, &moved),
|
||||
Some(moved.elements()[0].end_point().unwrap() - was.elements()[0].end_point().unwrap()),
|
||||
"at {far}: moved"
|
||||
);
|
||||
let node = |bp: &BezPath| Node::path(NodeId(1), PathData::from_bezpath(bp), Appearance::default());
|
||||
let mut moved_node = node(&was);
|
||||
moved_node.transform(Affine::translate(by), true);
|
||||
assert!(anchors_moved(&node(&was), &moved_node), "at {far}: anchors moved");
|
||||
for &edit in edits {
|
||||
let mut els = moved.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) {
|
||||
p.y += edit;
|
||||
}
|
||||
let edited = BezPath::from_vec(els);
|
||||
assert_eq!(offset_between(&was, &edited), None, "at {far}: an anchor moved by {edit} pt");
|
||||
assert!(!anchors_moved(&node(&was), &node(&edited)), "at {far}: an anchor moved by {edit} pt, read from the anchors");
|
||||
// The third segment is a curve: its first handle.
|
||||
let mut els = moved.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::CurveTo(h, _, _)) = els.get_mut(3) {
|
||||
h.x += edit;
|
||||
}
|
||||
assert!(!anchors_moved(&node(&was), &node(&BezPath::from_vec(els))), "at {far}: a handle moved by {edit} pt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_near_the_origin_is_not_taken_for_a_move_when_the_path_starts_far_from_it() {
|
||||
// A subpath at 1e16 pt, where coordinates step by 2 pt, then one near the origin.
|
||||
let mut was = BezPath::new();
|
||||
was.move_to((1e16, 1e16));
|
||||
was.line_to((1e16 + 64.0, 1e16));
|
||||
was.extend(zigzag(20));
|
||||
let by = Vec2::new(0.25, 3.0);
|
||||
let moved = Affine::translate(by) * was.clone();
|
||||
assert_eq!(offset_between(&was, &moved), Some(moved.elements()[0].end_point().unwrap() - was.elements()[0].end_point().unwrap()));
|
||||
let mut els = moved.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) {
|
||||
p.y += 0.0005;
|
||||
assert_eq!(offset_between(&was, &moved), Some(by), "moved: the offset of the subpath near the origin");
|
||||
let node = |bp: &BezPath| Node::path(NodeId(1), PathData::from_bezpath(bp), Appearance::default());
|
||||
let mut moved_node = node(&was);
|
||||
moved_node.transform(Affine::translate(by), true);
|
||||
assert!(anchors_moved(&node(&was), &moved_node), "anchors moved");
|
||||
// The first and the third anchor of the subpath near the origin.
|
||||
for at in [2, 4] {
|
||||
for edit in [1.0, 0.5] {
|
||||
let mut els = moved.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) = els.get_mut(at) {
|
||||
p.y += edit;
|
||||
}
|
||||
let edited = BezPath::from_vec(els);
|
||||
assert_eq!(offset_between(&was, &edited), None, "element {at} moved by {edit} pt");
|
||||
assert!(!anchors_moved(&node(&was), &node(&edited)), "element {at} moved by {edit} pt, read from the anchors");
|
||||
}
|
||||
}
|
||||
assert_eq!(offset_between(&was, &BezPath::from_vec(els)), None, "an anchor moved by 0.0005 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_each_near_the_one_before_are_checked_against_one_offset() {
|
||||
let path = |xs: [f64; 3]| {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((xs[0], 0.0));
|
||||
bp.line_to((xs[1], 0.0));
|
||||
bp.line_to((xs[2], 0.0));
|
||||
bp
|
||||
};
|
||||
let node = |bp: &BezPath| Node::path(NodeId(1), PathData::from_bezpath(bp), Appearance::default());
|
||||
let was = path([1e16, 1e15, 1e14]);
|
||||
// Coordinates step by 2, 0.125 and 0.015625 pt there.
|
||||
let moved = Affine::translate((6.25, 0.0)) * was.clone();
|
||||
assert_eq!(offset_between(&was, &moved), Some(Vec2::new(6.25, 0.0)), "moved");
|
||||
assert!(anchors_moved(&node(&was), &node(&moved)), "anchors moved");
|
||||
// Each point is within rounding of the one before it, but the first did not move.
|
||||
let edited = path([1e16, 1e15 + 6.0, 1e14 + 6.25]);
|
||||
assert_eq!(offset_between(&was, &edited), None);
|
||||
assert!(!anchors_moved(&node(&was), &node(&edited)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user