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>
125 lines
4.6 KiB
Rust
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)));
|
|
}
|