5d4a2c8fe1
- Explorer: every non-hidden file is listed with its own icon and tab kind (image, video, audio, pdf, code, text, other); settings show_all_types, hidden_extensions and show_annotation_files; kinds without a viewer open a placeholder card, never the text editor - crates/annotate: non-destructive image annotations (strokes, lines, rectangles, ellipses, text) with strict sidecar JSON validation, hit testing, zoom/pan transforms and undo - engine: file.info, file.hash, file.neighbours, image.annotations.load and save (a corrupt sidecar is kept as .bad, the image is never modified), header-only image sizing for PNG, JPEG, GIF, WebP, BMP and SVG - sync server: publish notes or folders as static HTML (raw HTML escaped, strict CSP, publish: false honoured, no attachments) and expiring revocable share links, with per-route rate limits and audit rows - ui-egui: "Last edit - name, time ago" in the status bar from server.file_meta - specs and designs: file viewers spec, 20 PaperCraft exports rated 8.2 of 10 (design/files/RATING.md), annotate and viewer briefs Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
142 lines
6.6 KiB
Rust
142 lines
6.6 KiB
Rust
//! Hit testing and polyline simplification tests.
|
|
|
|
use notescraft_annotate::{Annotations, ColorRef, Item, Point, StrokeTool, simplify};
|
|
|
|
/// A line item helper.
|
|
fn line(from: (f32, f32), to: (f32, f32)) -> Item {
|
|
Item::Line { color: ColorRef::Theme(0), width: 2.0, from: Point::new(from.0, from.1), to: Point::new(to.0, to.1), arrow: false }
|
|
}
|
|
|
|
#[test]
|
|
fn hit_prefers_the_topmost_and_respects_tolerance() {
|
|
let mut notes = Annotations::new(400, 300);
|
|
notes.items.push(line((0.0, 0.0), (100.0, 0.0)));
|
|
notes.items.push(line((0.0, 0.0), (100.0, 0.0)));
|
|
// Both segments coincide; the eraser takes the last (topmost) one.
|
|
assert_eq!(notes.hit(Point::new(50.0, 1.0), 2.0), Some(1));
|
|
assert_eq!(notes.hit(Point::new(50.0, 2.0), 2.0), Some(1));
|
|
// Just outside the tolerance for this y: no hit at all.
|
|
assert_eq!(notes.hit(Point::new(50.0, 2.0), 1.0), None);
|
|
assert_eq!(notes.hit(Point::new(50.0, 3.0), 2.0), None);
|
|
// After the topmost one is erased, the bottom one is reachable again.
|
|
let removed = notes.remove(1);
|
|
assert_eq!(removed, Some(line((0.0, 0.0), (100.0, 0.0))));
|
|
assert_eq!(notes.hit(Point::new(50.0, 1.0), 2.0), Some(0));
|
|
assert_eq!(notes.remove(9), None);
|
|
assert!(notes.remove(0).is_some_and(|item| item == line((0.0, 0.0), (100.0, 0.0))));
|
|
}
|
|
|
|
#[test]
|
|
fn hit_on_every_item_kind() {
|
|
let items = [
|
|
Item::Stroke { tool: StrokeTool::Pen, color: ColorRef::Theme(0), width: 1.0, points: vec![Point::new(10.0, 10.0), Point::new(30.0, 30.0)] },
|
|
Item::Stroke { tool: StrokeTool::Highlighter, color: ColorRef::Theme(1), width: 1.0, points: vec![Point::new(50.0, 50.0)] },
|
|
Item::Line { color: ColorRef::Theme(0), width: 1.0, from: Point::new(100.0, 10.0), to: Point::new(100.0, 100.0), arrow: true },
|
|
Item::Rect { color: ColorRef::Theme(0), width: 1.0, min: Point::new(200.0, 10.0), max: Point::new(250.0, 60.0), filled: false },
|
|
Item::Ellipse { color: ColorRef::Theme(0), width: 1.0, min: Point::new(270.0, 10.0), max: Point::new(320.0, 60.0), filled: false },
|
|
Item::Ellipse { color: ColorRef::Theme(0), width: 1.0, min: Point::new(340.0, 60.0), max: Point::new(390.0, 10.0), filled: true },
|
|
Item::Text { color: ColorRef::Theme(0), size: 20.0, at: Point::new(10.0, 100.0), text: "abcd".to_string() },
|
|
];
|
|
let mut notes = Annotations::new(400, 300);
|
|
notes.items.extend(items);
|
|
// diagonal stroke: on the path, and one point stroke near its only point
|
|
assert_eq!(notes.hit(Point::new(20.0, 20.0), 1.0), Some(0));
|
|
assert_eq!(notes.hit(Point::new(50.0, 46.0), 5.0), Some(1));
|
|
assert_eq!(notes.hit(Point::new(50.0, 40.0), 5.0), None);
|
|
// vertical line
|
|
assert_eq!(notes.hit(Point::new(101.0, 50.0), 2.0), Some(2));
|
|
assert_eq!(notes.hit(Point::new(110.0, 50.0), 2.0), None);
|
|
// rect (and its corners)
|
|
assert_eq!(notes.hit(Point::new(220.0, 30.0), 0.5), Some(3));
|
|
// ellipse via its box
|
|
assert_eq!(notes.hit(Point::new(300.0, 30.0), 0.5), Some(4));
|
|
// ellipse with crossed corners normalises to the same box
|
|
assert_eq!(notes.hit(Point::new(350.0, 30.0), 0.5), Some(5));
|
|
// just outside the rect
|
|
assert_eq!(notes.hit(Point::new(260.0, 30.0), 1.0), None);
|
|
// text label: inside the approximated box (0.6 * 20 * 4 wide, 20 tall)
|
|
assert_eq!(notes.hit(Point::new(30.0, 110.0), 2.0), Some(6));
|
|
assert_eq!(notes.hit(Point::new(80.0, 110.0), 2.0), None);
|
|
// nothing hit far away
|
|
assert_eq!(notes.hit(Point::new(399.0, 299.0), 2.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn bounds_union_every_item() {
|
|
let mut notes = Annotations::new(400, 300);
|
|
assert_eq!(notes.bounds(), None);
|
|
notes.items.push(line((10.0, 20.0), (110.0, 120.0)));
|
|
let area = notes.bounds().unwrap();
|
|
assert_eq!(area.min, Point::new(10.0, 20.0));
|
|
assert_eq!(area.max, Point::new(110.0, 120.0));
|
|
notes.items.push(Item::Stroke {
|
|
tool: StrokeTool::Pen,
|
|
color: ColorRef::Theme(0),
|
|
width: 1.0,
|
|
points: vec![Point::new(-5.0, 5.0), Point::new(200.0, 60.0)],
|
|
});
|
|
let joined = notes.bounds().unwrap();
|
|
assert_eq!(joined.min, Point::new(-5.0, 5.0));
|
|
assert_eq!(joined.max, Point::new(200.0, 120.0));
|
|
// An empty stroke bounds nothing but does not break the union.
|
|
let mut empty = Annotations::new(400, 300);
|
|
empty.items.push(Item::Stroke { tool: StrokeTool::Pen, color: ColorRef::Theme(0), width: 1.0, points: Vec::new() });
|
|
assert_eq!(empty.bounds(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn simplify_never_grows_and_keeps_endpoints() {
|
|
// The deterministic LCG from `crates/sync/tests/merge_props.rs`.
|
|
struct Rng(u64);
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
|
self.0 >> 33
|
|
}
|
|
fn below(&mut self, n: u64) -> u64 {
|
|
self.next() % n.max(1)
|
|
}
|
|
fn unit(&mut self) -> f32 {
|
|
let sample = u16::try_from(self.next() % 4096).unwrap_or(0);
|
|
f32::from(sample) / 4096.0
|
|
}
|
|
}
|
|
let mut rng = Rng(0x5EED_5107);
|
|
for case in 0..300_u64 {
|
|
let _ = case;
|
|
let count = 1 + usize::from(u16::try_from(rng.below(300)).unwrap_or(1));
|
|
let mut points = Vec::with_capacity(count);
|
|
for index in 0..count {
|
|
let wander = if index % 2 == 0 { 60.0 } else { -60.0 };
|
|
points.push(Point::new(
|
|
f32::from(u16::try_from(index).unwrap_or(0)) * 2.0 + rng.unit() * 5.0,
|
|
f32::from(u16::try_from(index).unwrap_or(0)) * 2.0 + wander + rng.unit() * 5.0,
|
|
));
|
|
}
|
|
let tolerance = rng.unit() * 50.0;
|
|
let trimmed = simplify(&points, tolerance);
|
|
assert!(trimmed.len() <= points.len(), "case {case} grew");
|
|
assert_eq!(trimmed.first(), points.first());
|
|
assert_eq!(trimmed.last(), points.last());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn simplify_is_iterative_on_a_100000_point_line() {
|
|
let count = 100_000;
|
|
let mut points = Vec::with_capacity(count);
|
|
for index in 0..count {
|
|
let wobble = if index % 2 == 0 { 0.5 } else { -0.5 };
|
|
let along = f32::from(u16::try_from(index % 1000).unwrap_or(0)) + f32::from(u16::try_from(index / 1000).unwrap_or(0));
|
|
points.push(Point::new(along, along * 0.7 + wobble));
|
|
}
|
|
let trimmed = simplify(&points, 10.0);
|
|
// A slightly wobbly straight line collapses to its endpoints.
|
|
assert_eq!(trimmed.len(), 2);
|
|
assert_eq!(trimmed[0], points[0]);
|
|
assert_eq!(trimmed[1], points[count - 1]);
|
|
// Tolerance below the wobble keeps far-off kinks.
|
|
let trimmed = simplify(&points, 0.1);
|
|
assert!(trimmed.len() >= 3);
|
|
}
|