Files
Marcos Paulo 5d4a2c8fe1
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: all files in the Explorer, publish and share links, image annotation core, last-edit line
- 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>
2026-10-20 14:38:47 -03:00

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);
}