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

125 lines
4.6 KiB
Rust

//! Geometry tests: the camera transforms, hit testing and polyline simplification.
use notescraft_annotate::{Point, Size, Transform};
/// 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)
}
/// A pseudo-random unit in 0.0..1.0.
fn unit(&mut self) -> f32 {
let sample = u16::try_from(self.below(4096)).unwrap_or(0);
f32::from(sample) / 4096.0
}
}
/// A point pair within an epsilon of each other.
fn close(left: Point, right: Point, epsilon: f32) -> bool {
(left.x - right.x).abs() <= epsilon && (left.y - right.y).abs() <= epsilon
}
/// A camera that has gone through a few random operations.
fn random_camera(rng: &mut Rng) -> Transform {
let mut camera = Transform::new(1000, 800);
let viewport = Size { width: 1440, height: 900 };
let steps = rng.below(8);
for _ in 0..steps {
match rng.below(4) {
0 => camera.zoom_at(Point::new(rng.unit() * 1440.0, rng.unit() * 900.0), 0.1 + rng.unit() * 4.0),
1 => camera.rotate(i8::try_from(rng.below(5)).unwrap_or(0) - 2),
2 => camera.fit(viewport),
_ => camera.actual_size(viewport),
}
}
camera
}
#[test]
fn transform_inverse_is_identity() {
let mut rng = Rng(0x5EED_2026);
for case in 0..500_u64 {
let camera = random_camera(&mut rng);
for sample in 0..10_u32 {
let point = Point::new(f32::from(u16::try_from(sample).unwrap_or(0)) * 100.0, f32::from(u16::try_from(sample % 8).unwrap_or(0)) * 100.0);
let screen = camera.to_screen(point);
let back = camera.to_image(screen);
assert!(close(back, point, 0.5), "case {case}: {point:?} -> {screen:?} -> {back:?}");
}
}
}
#[test]
fn zoom_keeps_the_point_under_the_cursor() {
let mut rng = Rng(0xCAFE_0001);
for case in 0..200_u64 {
let mut camera = random_camera(&mut rng);
let cursor = Point::new(rng.unit() * 1440.0, rng.unit() * 900.0);
let factor = 0.05 + rng.unit() * 10.0;
camera.zoom_at(cursor, factor);
// The image pixel seen at the cursor before the zoom is seen there after.
let seen = camera.to_image(cursor);
let at = camera.to_screen(seen);
assert!(close(at, cursor, 0.5), "case {case}: cursor {cursor:?} moved to {at:?}");
}
}
#[test]
fn zoom_is_clamped() {
let mut camera = Transform::new(1000, 800);
camera.zoom_at(Point::new(10.0, 10.0), 10_000.0);
assert_eq!(camera.zoom(), notescraft_annotate::MAX_ZOOM);
camera.zoom_at(Point::new(10.0, 10.0), 1.0 / 10_000.0);
assert_eq!(camera.zoom(), notescraft_annotate::MIN_ZOOM);
// Hostile factors leave the camera unchanged.
let before = camera;
camera.zoom_at(Point::new(10.0, 10.0), 0.0);
camera.zoom_at(Point::new(10.0, 10.0), -2.0);
camera.zoom_at(Point::new(10.0, 10.0), f32::NAN);
camera.zoom_at(Point::new(f32::INFINITY, 10.0), 2.0);
assert_eq!(camera, before);
}
#[test]
fn fit_and_actual_size() {
let mut camera = Transform::new(800, 600);
camera.fit(Size { width: 1440, height: 900 });
// fit zoom = min(1440/800, 900/600), image centre under the viewport centre.
assert_eq!(camera.zoom(), (1440.0f32 / 800.0).min(900.0 / 600.0));
let image_centre = Point::new(400.0, 300.0);
assert!(close(camera.to_screen(image_centre), Point::new(720.0, 450.0), 0.01));
camera.actual_size(Size { width: 1440, height: 900 });
assert_eq!(camera.zoom(), 1.0);
assert!(close(camera.to_screen(image_centre), Point::new(720.0, 450.0), 0.01));
// Degenerate viewports keep the camera sane (zoom 1 fallback, no panic).
camera.fit(Size { width: 0, height: 0 });
assert_eq!(camera.zoom(), 1.0);
assert!(camera.to_image(Point::new(3.0, 4.0)).x.is_finite());
}
#[test]
fn rotation_cycles_after_four_turns() {
let mut camera = Transform::new(200, 100);
let first = camera.to_screen(Point::new(30.0, 40.0));
for _ in 0..4 {
camera.rotate(1);
}
assert_eq!(camera.turns(), 0);
assert!(close(camera.to_screen(Point::new(30.0, 40.0)), first, 0.01));
// rotate(-3) must land on the same mapping as rotate(1).
let mut quarter = Transform::new(200, 100);
quarter.rotate(1);
let mut back = Transform::new(200, 100);
back.rotate(-3);
assert_eq!(quarter.turns(), back.turns());
assert_eq!(quarter.to_image(Point::new(50.0, 60.0)), back.to_image(Point::new(50.0, 60.0)));
}